Repository: playcanvas/engine
Pull request #5552 (2023-08-04 16:47:09)

Shader generators are converted to modules
- new base class for shader generators, called `ShaderGenerator`, implements functionality from now deleted `common.js` as static functions (so that they can be called from outside of generators too)
- all shader generator (basic, standard, particle, sky, pass-through) extend this class, and generate and export a single instance of it


Patch:
diff --git a/examples/package.json b/examples/package.json
index 690320341..950c1e7f7 100644
--- a/examples/package.json
+++ b/examples/package.json
@@ -16,7 +16,7 @@
     "watch": "npm run build:example:data && npm run build:directory && cross-env concurrently --kill-others \"npm run rollup:watch\"",
     "watch:debug": "ENGINE_PATH=../build/playcanvas.dbg.js npm run watch",
     "watch:profiler": "ENGINE_PATH=../build/playcanvas.prf.js npm run watch",
-    "develop": "cross-env concurrently --kill-others \"npm run watch\" \"npm run serve --no-request-logging\""
+    "develop": "cross-env concurrently --kill-others \"npm run watch\" \"npm run serve\""
   },
   "eslintConfig": {
     "root": true,
diff --git a/examples/src/app/example.tsx b/examples/src/app/example.tsx
index d46cdd0dd..926b9805a 100644
--- a/examples/src/app/example.tsx
+++ b/examples/src/app/example.tsx
@@ -3,7 +3,7 @@ import { withRouter } from 'react-router-dom';
 import { Container, Spinner, SelectInput, Panel } from '@playcanvas/pcui/react';
 import { SelectInput as SelectInputClass } from '@playcanvas/pcui';
 import { Observer } from '@playcanvas/observer';
-import { DEVICETYPE_WEBGL1, DEVICETYPE_WEBGL2, DEVICETYPE_WEBGPU } from '../../../build/playcanvas.js';
+import { DEVICETYPE_WEBGL1, DEVICETYPE_WEBGL2, DEVICETYPE_WEBGPU, DEVICETYPE_NULL } from '../../../build/playcanvas.js';
 import { File } from './helpers/types';
 import examples from './helpers/example-data.mjs';
 import ControlPanel from './control-panel';
@@ -12,7 +12,8 @@ import { MIN_DESKTOP_WIDTH } from './constants';
 const deviceTypeNames = {
     [DEVICETYPE_WEBGL1]: 'WebGL 1',
     [DEVICETYPE_WEBGL2]: 'WebGL 2',
-    [DEVICETYPE_WEBGPU]: 'WebGPU'
+    [DEVICETYPE_WEBGPU]: 'WebGPU',
+    [DEVICETYPE_NULL]: 'Null'
 };
 
 const controlsObserver = new Observer();
@@ -149,7 +150,7 @@ class Example extends Component <ExampleProps, ExampleState> {
             this.deviceTypeSelectInput.value = value;
         }
         this.setDisabledOptions(this.preferredGraphicsDevice, value);
-        document.getElementById('showMiniStatsButton').ui.enabled = value !== DEVICETYPE_WEBGPU;
+        document.getElementById('showMiniStatsButton').ui.enabled = (value !== DEVICETYPE_WEBGPU) && (value !== DEVICETYPE_NULL);
     };
 
     onSetPreferredGraphicsDevice = (value: string) => {
@@ -190,7 +191,8 @@ class Example extends Component <ExampleProps, ExampleState> {
                     options={[
                         { t: deviceTypeNames[DEVICETYPE_WEBGL1], v: DEVICETYPE_WEBGL1 },
                         { t: deviceTypeNames[DEVICETYPE_WEBGL2], v: DEVICETYPE_WEBGL2 },
-                        { t: deviceTypeNames[DEVICETYPE_WEBGPU], v: DEVICETYPE_WEBGPU }
+                        { t: deviceTypeNames[DEVICETYPE_WEBGPU], v: DEVICETYPE_WEBGPU },
+                        { t: deviceTypeNames[DEVICETYPE_NULL], v: DEVICETYPE_NULL }
                     ]}
                     onSelect={this.onSetPreferredGraphicsDevice}
                     prefix='Active Device: '
diff --git a/examples/src/examples/loaders/gltf-export.tsx b/examples/src/examples/loaders/gltf-export.tsx
index db35f2e62..6b8280491 100644
--- a/examples/src/examples/loaders/gltf-export.tsx
+++ b/examples/src/examples/loaders/gltf-export.tsx
@@ -31,7 +31,8 @@ class GltfExportExample {
                 'helipad': new pc.Asset('helipad-env-atlas', 'texture', { url: '/static/assets/cubemaps/helipad-env-atlas.png' }, { type: pc.TEXTURETYPE_RGBP, mipmaps: false }),
                 'bench': new pc.Asset('bench', 'container', { url: '/static/assets/models/bench_wooden_01.glb' }),
                 'model': new pc.Asset('model', 'container', { url: '/static/assets/models/bitmoji.glb' }),
-                'board': new pc.Asset('statue', 'container', { url: '/static/assets/models/chess-board.glb' })
+                'board': new pc.Asset('statue', 'container', { url: '/static/assets/models/chess-board.glb' }),
+                'color': new pc.Asset('color', 'texture', { url: '/static/assets/textures/seaside-rocks01-color.jpg' })
             };
 
             const gfxOptions = {
@@ -102,6 +103,19 @@ class GltfExportExample {
                     app.root.addChild(entity);
                     entity.setLocalPosition(0, 1.5, -1.5);
 
+                    // mesh with a basic material
+                    const basicMaterial = new pc.BasicMaterial();
+                    basicMaterial.color.set(0.5, 1.0, 0.7);
+                    basicMaterial.colorMap = assets.color.resource;
+
+                    const capsule = new pc.Entity('capsule');
+                    capsule.addComponent('render', {
+                        material: basicMaterial,
+                        type: 'capsule'
+                    });
+                    capsule.setLocalPosition(0.5, 2.0, -0.5);
+                    app.root.addChild(capsule);
+
                     // Create an Entity with a camera component
                     const camera = new pc.Entity();
                     camera.addComponent("camera", {
diff --git a/examples/src/iframe/index.html b/examples/src/iframe/index.html
index aba9d59cf..a7fa3c8be 100644
--- a/examples/src/iframe/index.html
+++ b/examples/src/iframe/index.html
@@ -131,7 +131,8 @@
                 }).observe(canvasContainerElement);
             }
 
-            if (app.graphicsDevice.deviceType !== 'webgpu' && !Example.MINISTATS) {
+            const deviceType = app.graphicsDevice.deviceType;
+            if (deviceType !== 'webgpu' && deviceType !== 'null' && !Example.MINISTATS) {
                 // set up miniStats
                 var miniStats = new pcx.MiniStats(app);
                 if (urlParams.get('miniStats') === 'false') {
diff --git a/extras/exporters/gltf-exporter.js b/extras/exporters/gltf-exporter.js
index 10c21b36d..df14b02f5 100644
--- a/extras/exporters/gltf-exporter.js
+++ b/extras/exporters/gltf-exporter.js
@@ -18,7 +18,9 @@ import {
     Color,
     Quat,
     Vec3,
-    VertexBuffer
+    VertexBuffer,
+    StandardMaterial,
+    BasicMaterial
 } from 'playcanvas';
 
 const ARRAY_BUFFER = 34962;
@@ -96,7 +98,8 @@ const getWrap = function (wrap) {
 
 // supported texture semantics on a material
 const textureSemantics = [
-    'diffuseMap'
+    'diffuseMap',
+    'colorMap'
 ];
 
 class GltfExporter extends CoreExporter {
@@ -286,39 +289,64 @@ class GltfExporter extends CoreExporter {
         }
     }
 
-    writeMaterials(resources, json) {
+    attachTexture(resources, material, destination, name, textureSemantic) {
+        const texture = material[textureSemantic];
+        if (texture) {
+            const textureIndex = resources.textures.indexOf(texture);
+            if (textureIndex < 0) console.logWarn(`Texture ${texture.name} wasn't collected.`);
+            destination[name] = {
+                "index": textureIndex
+            };
+        }
+    }
 
-        const attachTexture = (material, destination, name, textureSemantic) => {
-            const texture = material[textureSemantic];
-            if (texture) {
-                const textureIndex = resources.textures.indexOf(texture);
-                if (textureIndex < 0) console.logWarn(`Texture ${texture.name} wasn't collected.`);
-                destination[name] = {
-                    "index": textureIndex
-                };
-            }
-        };
+    writeStandardMaterial(resources, mat, output) {
+
+        const { diffuse, emissive, opacity } = mat;
+        const pbr = output.pbrMetallicRoughness;
+
+        if (!diffuse.equals(Color.WHITE) || opacity !== 1) {
+            pbr.baseColorFactor = [diffuse.r, diffuse.g, diffuse.b, opacity];
+        }
+
+        this.attachTexture(resources, mat, pbr, 'baseColorTexture', 'diffuseMap');
+
+        if (!emissive.equals(Color.BLACK)) {
+            output.emissiveFactor = [emissive.r, emissive.g, emissive.b];
+        }
+    }
+
+    writeBasicMaterial(resources, mat, output) {
+
+        const { color } = mat;
+        const pbr = output.pbrMetallicRoughness;
+
+        if (!color.equals(Color.WHITE)) {
+            pbr.baseColorFactor = [color.r, color.g, color.b, color];
+        }
+
+        this.attachTexture(resources, mat, pbr, 'baseColorTexture', 'colorMap');
+    }
+
+    writeMaterials(resources, json) {
 
         if (resources.materials.length > 0) {
             json.materials = resources.materials.map((mat) => {
-                const { name, diffuse, emissive, opacity, blendType, cull } = mat;
+                const { name, blendType, cull } = mat;
                 const material = {
                     pbrMetallicRoughness: {}
                 };
-                const pbr = material.pbrMetallicRoughness;
 
                 if (name && name.length > 0) {
                     material.name = name;
                 }
 
-                if (!diffuse.equals(Color.WHITE) || opacity !== 1) {
-                    pbr.baseColorFactor = [diffuse.r, diffuse.g, diffuse.b, opacity];
+                if (mat instanceof StandardMaterial) {
+                    this.writeStandardMaterial(resources, mat, material);
                 }
 
-                attachTexture(mat, pbr, 'baseColorTexture', 'diffuseMap');
-
-                if (!emissive.equals(Color.BLACK)) {
-                    material.emissiveFactor = [emissive.r, emissive.g, emissive.b];
+                if (mat instanceof BasicMaterial) {
+                    this.writeBasicMaterial(resources, mat, material);
                 }
 
                 if (blendType === BLEND_NORMAL) {
diff --git a/package-lock.json b/package-lock.json
index 9592ebb56..5e87249dd 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,21 +9,22 @@
       "version": "1.66.0-dev",
       "license": "MIT",
       "devDependencies": {
-        "@babel/core": "^7.22.9",
-        "@babel/eslint-parser": "^7.22.9",
-        "@babel/preset-env": "^7.22.9",
+        "@babel/core": "^7.22.10",
+        "@babel/eslint-parser": "^7.22.10",
+        "@babel/preset-env": "^7.22.10",
         "@playcanvas/canvas-mock": "^1.0.1",
-        "@playcanvas/eslint-config": "^1.5.0",
+        "@playcanvas/eslint-config": "^1.6.0",
         "@playcanvas/jsdoc-template": "^1.1.1",
         "@rollup/plugin-babel": "^6.0.3",
         "@rollup/plugin-node-resolve": "^15.1.0",
         "@rollup/plugin-strip": "^3.0.2",
         "@rollup/plugin-terser": "^0.4.3",
-        "@rollup/pluginutils": "^5.0.2",
+        "@rollup/pluginutils": "^5.0.3",
+        "@types/webxr": "^0.5.3",
         "@webgpu/types": "^0.1.34",
         "c8": "^8.0.0",
         "chai": "^4.3.7",
-        "eslint": "^8.45.0",
+        "eslint": "^8.47.0",
         "fflate": "^0.8.0",
         "jsdoc": "^4.0.2",
         "jsdoc-tsimport-plugin": "^1.0.5",
@@ -32,8 +33,8 @@
         "karma-mocha": "2.0.1",
         "karma-spec-reporter": "^0.0.36",
         "mocha": "^10.2.0",
-        "rollup": "^3.26.3",
-        "rollup-plugin-dts": "^5.3.0",
+        "rollup": "^3.28.0",
+        "rollup-plugin-dts": "^5.3.1",
         "rollup-plugin-jscc": "2.0.0",
         "rollup-plugin-visualizer": "^5.9.2",
         "serve": "^14.2.0",
@@ -67,12 +68,13 @@
       }
     },
     "node_modules/@babel/code-frame": {
-      "version": "7.22.5",
-      "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.5.tgz",
-      "integrity": "sha512-Xmwn266vad+6DAqEB2A6V/CcZVp62BbwVmcOJc2RPuwih1kw02TjQvWVWlcKGbBPd+8/0V5DEkOcizRGYsspYQ==",
+      "version": "7.22.10",
+      "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.10.tgz",
+      "integrity": "sha512-/KKIMG4UEL35WmI9OlvMhurwtytjvXoFcGNrOvyG9zIzA8YmPjVtIZUf7b05+TPO7G7/GEmLHDaoCgACHl9hhA==",
       "dev": true,
       "dependencies": {
-        "@babel/highlight": "^7.22.5"
+        "@babel/highlight": "^7.22.10",
+        "chalk": "^2.4.2"
       },
       "engines": {
         "node": ">=6.9.0"
@@ -88,21 +90,21 @@
       }
     },
     "node_modules/@babel/core": {
-      "version": "7.22.9",
-      "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.22.9.tgz",
-      "integrity": "sha512-G2EgeufBcYw27U4hhoIwFcgc1XU7TlXJ3mv04oOv1WCuo900U/anZSPzEqNjwdjgffkk2Gs0AN0dW1CKVLcG7w==",
+      "version": "7.22.10",
+      "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.22.10.tgz",
+      "integrity": "sha512-fTmqbbUBAwCcre6zPzNngvsI0aNrPZe77AeqvDxWM9Nm+04RrJ3CAmGHA9f7lJQY6ZMhRztNemy4uslDxTX4Qw==",
       "dev": true,
       "dependencies": {
         "@ampproject/remapping": "^2.2.0",
-        "@babel/code-frame": "^7.22.5",
-        "@babel/generator": "^7.22.9",
-        "@babel/helper-compilation-targets": "^7.22.9",
+        "@babel/code-frame": "^7.22.10",
+        "@babel/generator": "^7.22.10",
+        "@babel/helper-compilation-targets": "^7.22.10",
         "@babel/helper-module-transforms": "^7.22.9",
-        "@babel/helpers": "^7.22.6",
-        "@babel/parser": "^7.22.7",
+        "@babel/helpers": "^7.22.10",
+        "@babel/parser": "^7.22.10",
         "@babel/template": "^7.22.5",
-        "@babel/traverse": "^7.22.8",
-        "@babel/types": "^7.22.5",
+        "@babel/traverse": "^7.22.10",
+        "@babel/types": "^7.22.10",
         "convert-source-map": "^1.7.0",
         "debug": "^4.1.0",
         "gensync": "^1.0.0-beta.2",
@@ -118,9 +120,9 @@
       }
     },
     "node_modules/@babel/eslint-parser": {
-      "version": "7.22.9",
-      "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.22.9.tgz",
-      "integrity": "sha512-xdMkt39/nviO/4vpVdrEYPwXCsYIXSSAr6mC7WQsNIlGnuxKyKE7GZjalcnbSWiC4OXGNNN3UQPeHfjSC6sTDA==",
+      "version": "7.22.10",
+      "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.22.10.tgz",
+      "integrity": "sha512-0J8DNPRXQRLeR9rPaUMM3fA+RbixjnVLe/MRMYCkp3hzgsSuxCHQ8NN8xQG1wIHKJ4a1DTROTvFJdW+B5/eOsg==",
       "dev": true,
       "dependencies": {
         "@nicolo-ribaudo/eslint-scope-5-internals": "5.1.1-v1",
@@ -131,17 +133,17 @@
         "node": "^10.13.0 || ^12.13.0 || >=14.0.0"
       },
       "peerDependencies": {
-        "@babel/core": ">=7.11.0",
+        "@babel/core": "^7.11.0",
         "eslint": "^7.5.0 || ^8.0.0"
       }
     },
     "node_modules/@babel/generator": {
-      "version": "7.22.9",
-      "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.22.9.tgz",
-      "integrity": "sha512-KtLMbmicyuK2Ak/FTCJVbDnkN1SlT8/kceFTiuDiiRUUSMnHMidxSCdG4ndkTOHHpoomWe/4xkvHkEOncwjYIw==",
+      "version": "7.22.10",
+      "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.22.10.tgz",
+      "integrity": "sha512-79KIf7YiWjjdZ81JnLujDRApWtl7BxTqWD88+FFdQEIOG8LJ0etDOM7CXuIgGJa55sGOwZVwuEsaLEm0PJ5/+A==",
       "dev": true,
       "dependencies": {
-        "@babel/types": "^7.22.5",
+        "@babel/types": "^7.22.10",
         "@jridgewell/gen-mapping": "^0.3.2",
         "@jridgewell/trace-mapping": "^0.3.17",
         "jsesc": "^2.5.1"
@@ -175,9 +177,9 @@
       }
     },
     "node_modules/@babel/helper-compilation-targets": {
-      "version": "7.22.9",
-      "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.9.tgz",
-      "integrity": "sha512-7qYrNM6HjpnPHJbopxmb8hSPoZ0gsX8IvUS32JGVoy+pU9e5N0nLr1VjJoR6kA4d9dmGLxNYOjeB8sUDal2WMw==",
+      "version": "7.22.10",
+      "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.10.tgz",
+      "integrity": "sha512-JMSwHD4J7SLod0idLq5PKgI+6g/hLD/iuWBq08ZX49xE14VpVEojJ5rHWptpirV2j020MvypRLAXAO50igCJ5Q==",
       "dev": true,
       "dependencies": {
         "@babel/compat-data": "^7.22.9",
@@ -188,9 +190,6 @@
       },
       "engines": {
         "node": ">=6.9.0"
-      },
-      "peerDependencies": {
-        "@babel/core": "^7.0.0"
       }
     },
     "node_modules/@babel/helper-create-class-features-plugin": {
@@ -459,27 +458,27 @@
       }
     },
     "node_modules/@babel/helpers": {
-      "version": "7.22.6",
-      "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.22.6.tgz",
-      "integrity": "sha512-YjDs6y/fVOYFV8hAf1rxd1QvR9wJe1pDBZ2AREKq/SDayfPzgk0PBnVuTCE5X1acEpMMNOVUqoe+OwiZGJ+OaA==",
+      "version": "7.22.10",
+      "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.22.10.tgz",
+      "integrity": "sha512-a41J4NW8HyZa1I1vAndrraTlPZ/eZoga2ZgS7fEr0tZJGVU4xqdE80CEm0CcNjha5EZ8fTBYLKHF0kqDUuAwQw==",
       "dev": true,
       "dependencies": {
         "@babel/template": "^7.22.5",
-        "@babel/traverse": "^7.22.6",
-        "@babel/types": "^7.22.5"
+        "@babel/traverse": "^7.22.10",
+        "@babel/types": "^7.22.10"
       },
       "engines": {
         "node": ">=6.9.0"
       }
     },
     "node_modules/@babel/highlight": {
-      "version": "7.22.5",
-      "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.5.tgz",
-      "integrity": "sha512-BSKlD1hgnedS5XRnGOljZawtag7H1yPfQp0tdNJCHoH6AZ+Pcm9VvkrK59/Yy593Ypg0zMxH2BxD1VPYUQ7UIw==",
+      "version": "7.22.10",
+      "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.10.tgz",
+      "integrity": "sha512-78aUtVcT7MUscr0K5mIEnkwxPE0MaxkR5RxRwuHaQ+JuU5AmTPhY+do2mdzVTnIJJpyBglql2pehuBIWHug+WQ==",
       "dev": true,
       "dependencies": {
         "@babel/helper-validator-identifier": "^7.22.5",
-        "chalk": "^2.0.0",
+        "chalk": "^2.4.2",
         "js-tokens": "^4.0.0"
       },
       "engines": {
@@ -487,9 +486,9 @@
       }
     },
     "node_modules/@babel/parser": {
-      "version": "7.22.7",
-      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.22.7.tgz",
-      "integrity": "sha512-7NF8pOkHP5o2vpmGgNGcfAeCvOYhGLyA3Z4eBQkT1RJlWu47n63bCs93QfJ2hIAFCil7L5P2IWhs1oToVgrL0Q==",
+      "version": "7.22.10",
+      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.22.10.tgz",
+      "integrity": "sha512-lNbdGsQb9ekfsnjFGhEiF4hfFqGgfOP3H3d27re3n+CGhNuTSUEQdfWk556sTLNTloczcdM5TYF2LhzmDQKyvQ==",
       "dev": true,
       "bin": {
         "parser": "bin/babel-parser.js"
@@ -542,22 +541,6 @@
         "@babel/core": "^7.0.0-0"
       }
     },
-    "node_modules/@babel/plugin-proposal-unicode-property-regex": {
-      "version": "7.18.6",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.18.6.tgz",
-      "integrity": "sha512-2BShG/d5yoZyXZfVePH91urL5wTG6ASZU9M4o03lKK8u8UW1y08OMttBSOADTcJrnPMpvDXRG3G8fyLh4ovs8w==",
-      "dev": true,
-      "dependencies": {
-        "@babel/helper-create-regexp-features-plugin": "^7.18.6",
-        "@babel/helper-plugin-utils": "^7.18.6"
-      },
-      "engines": {
-        "node": ">=4"
-      },
-      "peerDependencies": {
-        "@babel/core": "^7.0.0-0"
-      }
-    },
     "node_modules/@babel/plugin-syntax-async-generators": {
       "version": "7.8.4",
       "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz",
@@ -809,14 +792,14 @@
       }
     },
     "node_modules/@babel/plugin-transform-async-generator-functions": {
-      "version": "7.22.7",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.22.7.tgz",
-      "integrity": "sha512-7HmE7pk/Fmke45TODvxvkxRMV9RazV+ZZzhOL9AG8G29TLrr3jkjwF7uJfxZ30EoXpO+LJkq4oA8NjO2DTnEDg==",
+      "version": "7.22.10",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.22.10.tgz",
+      "integrity": "sha512-eueE8lvKVzq5wIObKK/7dvoeKJ+xc6TvRn6aysIjS6pSCeLy7S/eVi7pEQknZqyqvzaNKdDtem8nUNTBgDVR2g==",
       "dev": true,
       "dependencies": {
         "@babel/helper-environment-visitor": "^7.22.5",
         "@babel/helper-plugin-utils": "^7.22.5",
-        "@babel/helper-remap-async-to-generator": "^7.22.5",
+        "@babel/helper-remap-async-to-generator": "^7.22.9",
         "@babel/plugin-syntax-async-generators": "^7.8.4"
       },
       "engines": {
@@ -859,9 +842,9 @@
       }
     },
     "node_modules/@babel/plugin-transform-block-scoping": {
-      "version": "7.22.5",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.22.5.tgz",
-      "integrity": "sha512-EcACl1i5fSQ6bt+YGuU/XGCeZKStLmyVGytWkpyhCLeQVA0eu6Wtiw92V+I1T/hnezUv7j74dA/Ro69gWcU+hg==",
+      "version": "7.22.10",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.22.10.tgz",
+      "integrity": "sha512-1+kVpGAOOI1Albt6Vse7c8pHzcZQdQKW+wJH+g8mCaszOdDVwRXa/slHPqIw+oJAJANTKDMuM2cBdV0Dg618Vg==",
       "dev": true,
       "dependencies": {
         "@babel/helper-plugin-utils": "^7.22.5"
@@ -946,9 +929,9 @@
       }
     },
     "node_modules/@babel/plugin-transform-destructuring": {
-      "version": "7.22.5",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.22.5.tgz",
-      "integrity": "sha512-GfqcFuGW8vnEqTUBM7UtPd5A4q797LTvvwKxXTgRsFjoqaJiEg9deBG6kWeQYkVEL569NpnmpC0Pkr/8BLKGnQ==",
+      "version": "7.22.10",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.22.10.tgz",
+      "integrity": "sha512-dPJrL0VOyxqLM9sritNbMSGx/teueHF/htMKrPT7DNxccXxRDPYqlgPFFdr8u+F+qUZOkZoXue/6rL5O5GduEw==",
       "dev": true,
       "dependencies": {
         "@babel/helper-plugin-utils": "^7.22.5"
@@ -1315,9 +1298,9 @@
       }
     },
     "node_modules/@babel/plugin-transform-optional-chaining": {
-      "version": "7.22.6",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.22.6.tgz",
-      "integrity": "sha512-Vd5HiWml0mDVtcLHIoEU5sw6HOUW/Zk0acLs/SAeuLzkGNOPc9DB4nkUajemhCmTIz3eiaKREZn2hQQqF79YTg==",
+      "version": "7.22.10",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.22.10.tgz",
+      "integrity": "sha512-MMkQqZAZ+MGj+jGTG3OTuhKeBpNcO+0oCEbrGNEaOmiEn+1MzRyQlYsruGiU8RTK3zV6XwrVJTmwiDOyYK6J9g==",
       "dev": true,
       "dependencies": {
         "@babel/helper-plugin-utils": "^7.22.5",
@@ -1396,13 +1379,13 @@
       }
     },
     "node_modules/@babel/plugin-transform-regenerator": {
-      "version": "7.22.5",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.22.5.tgz",
-      "integrity": "sha512-rR7KePOE7gfEtNTh9Qw+iO3Q/e4DEsoQ+hdvM6QUDH7JRJ5qxq5AA52ZzBWbI5i9lfNuvySgOGP8ZN7LAmaiPw==",
+      "version": "7.22.10",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.22.10.tgz",
+      "integrity": "sha512-F28b1mDt8KcT5bUyJc/U9nwzw6cV+UmTeRlXYIl2TNqMMJif0Jeey9/RQ3C4NOd2zp0/TRsDns9ttj2L523rsw==",
       "dev": true,
       "dependencies": {
         "@babel/helper-plugin-utils": "^7.22.5",
-        "regenerator-transform": "^0.15.1"
+        "regenerator-transform": "^0.15.2"
       },
       "engines": {
         "node": ">=6.9.0"
@@ -1503,9 +1486,9 @@
       }
     },
     "node_modules/@babel/plugin-transform-unicode-escapes": {
-      "version": "7.22.5",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.22.5.tgz",
-      "integrity": "sha512-biEmVg1IYB/raUO5wT1tgfacCef15Fbzhkx493D3urBI++6hpJ+RFG4SrWMn0NEZLfvilqKf3QDrRVZHo08FYg==",
+      "version": "7.22.10",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.22.10.tgz",
+      "integrity": "sha512-lRfaRKGZCBqDlRU3UIFovdp9c9mEvlylmpod0/OatICsSfuQ9YFthRo1tpTkGsklEefZdqlEFdY4A2dwTb6ohg==",
       "dev": true,
       "dependencies": {
         "@babel/helper-plugin-utils": "^7.22.5"
@@ -1566,13 +1549,13 @@
       }
     },
     "node_modules/@babel/preset-env": {
-      "version": "7.22.9",
-      "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.22.9.tgz",
-      "integrity": "sha512-wNi5H/Emkhll/bqPjsjQorSykrlfY5OWakd6AulLvMEytpKasMVUpVy8RL4qBIBs5Ac6/5i0/Rv0b/Fg6Eag/g==",
+      "version": "7.22.10",
+      "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.22.10.tgz",
+      "integrity": "sha512-riHpLb1drNkpLlocmSyEg4oYJIQFeXAK/d7rI6mbD0XsvoTOOweXDmQPG/ErxsEhWk3rl3Q/3F6RFQlVFS8m0A==",
       "dev": true,
       "dependencies": {
         "@babel/compat-data": "^7.22.9",
-        "@babel/helper-compilation-targets": "^7.22.9",
+        "@babel/helper-compilation-targets": "^7.22.10",
         "@babel/helper-plugin-utils": "^7.22.5",
         "@babel/helper-validator-option": "^7.22.5",
         "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.22.5",
@@ -1597,15 +1580,15 @@
         "@babel/plugin-syntax-top-level-await": "^7.14.5",
         "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6",
         "@babel/plugin-transform-arrow-functions": "^7.22.5",
-        "@babel/plugin-transform-async-generator-functions": "^7.22.7",
+        "@babel/plugin-transform-async-generator-functions": "^7.22.10",
         "@babel/plugin-transform-async-to-generator": "^7.22.5",
         "@babel/plugin-transform-block-scoped-functions": "^7.22.5",
-        "@babel/plugin-transform-block-scoping": "^7.22.5",
+        "@babel/plugin-transform-block-scoping": "^7.22.10",
         "@babel/plugin-transform-class-properties": "^7.22.5",
         "@babel/plugin-transform-class-static-block": "^7.22.5",
         "@babel/plugin-transform-classes": "^7.22.6",
         "@babel/plugin-transform-computed-properties": "^7.22.5",
-        "@babel/plugin-transform-destructuring": "^7.22.5",
+        "@babel/plugin-transform-destructuring": "^7.22.10",
         "@babel/plugin-transform-dotall-regex": "^7.22.5",
         "@babel/plugin-transform-duplicate-keys": "^7.22.5",
         "@babel/plugin-transform-dynamic-import": "^7.22.5",
@@ -1628,27 +1611,27 @@
         "@babel/plugin-transform-object-rest-spread": "^7.22.5",
         "@babel/plugin-transform-object-super": "^7.22.5",
         "@babel/plugin-transform-optional-catch-binding": "^7.22.5",
-        "@babel/plugin-transform-optional-chaining": "^7.22.6",
+        "@babel/plugin-transform-optional-chaining": "^7.22.10",
         "@babel/plugin-transform-parameters": "^7.22.5",
         "@babel/plugin-transform-private-methods": "^7.22.5",
         "@babel/plugin-transform-private-property-in-object": "^7.22.5",
         "@babel/plugin-transform-property-literals": "^7.22.5",
-        "@babel/plugin-transform-regenerator": "^7.22.5",
+        "@babel/plugin-transform-regenerator": "^7.22.10",
         "@babel/plugin-transform-reserved-words": "^7.22.5",
         "@babel/plugin-transform-shorthand-properties": "^7.22.5",
         "@babel/plugin-transform-spread": "^7.22.5",
         "@babel/plugin-transform-sticky-regex": "^7.22.5",
         "@babel/plugin-transform-template-literals": "^7.22.5",
         "@babel/plugin-transform-typeof-symbol": "^7.22.5",
-        "@babel/plugin-transform-unicode-escapes": "^7.22.5",
+        "@babel/plugin-transform-unicode-escapes": "^7.22.10",
         "@babel/plugin-transform-unicode-property-regex": "^7.22.5",
         "@babel/plugin-transform-unicode-regex": "^7.22.5",
         "@babel/plugin-transform-unicode-sets-regex": "^7.22.5",
-        "@babel/preset-modules": "^0.1.5",
-        "@babel/types": "^7.22.5",
-        "babel-plugin-polyfill-corejs2": "^0.4.4",
-        "babel-plugin-polyfill-corejs3": "^0.8.2",
-        "babel-plugin-polyfill-regenerator": "^0.5.1",
+        "@babel/preset-modules": "0.1.6-no-external-plugins",
+        "@babel/types": "^7.22.10",
+        "babel-plugin-polyfill-corejs2": "^0.4.5",
+        "babel-plugin-polyfill-corejs3": "^0.8.3",
+        "babel-plugin-polyfill-regenerator": "^0.5.2",
         "core-js-compat": "^3.31.0",
         "semver": "^6.3.1"
       },
@@ -1660,14 +1643,12 @@
       }
     },
     "node_modules/@babel/preset-modules": {
-      "version": "0.1.6",
-      "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6.tgz",
-      "integrity": "sha512-ID2yj6K/4lKfhuU3+EX4UvNbIt7eACFbHmNUjzA+ep+B5971CknnA/9DEWKbRokfbbtblxxxXFJJrH47UEAMVg==",
+      "version": "0.1.6-no-external-plugins",
+      "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz",
+      "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==",
       "dev": true,
       "dependencies": {
         "@babel/helper-plugin-utils": "^7.0.0",
-        "@babel/plugin-proposal-unicode-property-regex": "^7.4.4",
-        "@babel/plugin-transform-dotall-regex": "^7.4.4",
         "@babel/types": "^7.4.4",
         "esutils": "^2.0.2"
       },
@@ -1682,12 +1663,12 @@
       "dev": true
     },
     "node_modules/@babel/runtime": {
-      "version": "7.22.6",
-      "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.22.6.tgz",
-      "integrity": "sha512-wDb5pWm4WDdF6LFUde3Jl8WzPA+3ZbxYqkC6xAXuD3irdEHN1k0NfTRrJD8ZD378SJ61miMLCqIOXYhd8x+AJQ==",
+      "version": "7.22.10",
+      "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.22.10.tgz",
+      "integrity": "sha512-21t/fkKLMZI4pqP2wlmsQAWnYW1PDyKyyUV4vCi+B25ydmdaYTKXPwCj0BzSUnZf4seIiYvSA3jcZ3gdsMFkLQ==",
       "dev": true,
       "dependencies": {
-        "regenerator-runtime": "^0.13.11"
+        "regenerator-runtime": "^0.14.0"
       },
       "engines": {
         "node": ">=6.9.0"
@@ -1708,19 +1689,19 @@
       }
     },
     "node_modules/@babel/traverse": {
-      "version": "7.22.8",
-      "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.22.8.tgz",
-      "integrity": "sha512-y6LPR+wpM2I3qJrsheCTwhIinzkETbplIgPBbwvqPKc+uljeA5gP+3nP8irdYt1mjQaDnlIcG+dw8OjAco4GXw==",
+      "version": "7.22.10",
+      "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.22.10.tgz",
+      "integrity": "sha512-Q/urqV4pRByiNNpb/f5OSv28ZlGJiFiiTh+GAHktbIrkPhPbl90+uW6SmpoLyZqutrg9AEaEf3Q/ZBRHBXgxig==",
       "dev": true,
       "dependencies": {
-        "@babel/code-frame": "^7.22.5",
-        "@babel/generator": "^7.22.7",
+        "@babel/code-frame": "^7.22.10",
+        "@babel/generator": "^7.22.10",
         "@babel/helper-environment-visitor": "^7.22.5",
         "@babel/helper-function-name": "^7.22.5",
         "@babel/helper-hoist-variables": "^7.22.5",
         "@babel/helper-split-export-declaration": "^7.22.6",
-        "@babel/parser": "^7.22.7",
-        "@babel/types": "^7.22.5",
+        "@babel/parser": "^7.22.10",
+        "@babel/types": "^7.22.10",
         "debug": "^4.1.0",
         "globals": "^11.1.0"
       },
@@ -1729,9 +1710,9 @@
       }
     },
     "node_modules/@babel/types": {
-      "version": "7.22.5",
-      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.22.5.tgz",
-      "integrity": "sha512-zo3MIHGOkPOfoRXitsgHLjEXmlDaD/5KU1Uzuc9GNiZPhSqVxVRtxuPaSBZDsYZ9qV88AjtMtWW7ww98loJ9KA==",
+      "version": "7.22.10",
+      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.22.10.tgz",
+      "integrity": "sha512-obaoigiLrlDZ7TUQln/8m4mSqIW2QFeOrCQc9r+xsaHGNoplVNYlRVpsfE8Vj35GEm2ZH4ZhrNYogs/3fj85kg==",
       "dev": true,
       "dependencies": {
         "@babel/helper-string-parser": "^7.22.5",
@@ -1758,12 +1739,12 @@
       }
     },
     "node_modules/@es-joy/jsdoccomment": {
-      "version": "0.39.4",
-      "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.39.4.tgz",
-      "integrity": "sha512-Jvw915fjqQct445+yron7Dufix9A+m9j1fCJYlCo1FWlRvTxa3pjJelxdSTdaLWcTwRU6vbL+NYjO4YuNIS5Qg==",
+      "version": "0.40.1",
+      "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.40.1.tgz",
+      "integrity": "sha512-YORCdZSusAlBrFpZ77pJjc5r1bQs5caPWtAu+WWmiSo+8XaUzseapVrfAtiRFbQWnrBxxLLEwF6f6ZG/UgCQCg==",
       "dev": true,
       "dependencies": {
-        "comment-parser": "1.3.1",
+        "comment-parser": "1.4.0",
         "esquery": "^1.5.0",
         "jsdoc-type-pratt-parser": "~4.0.0"
       },
@@ -1808,9 +1789,9 @@
       }
     },
     "node_modules/@eslint/eslintrc": {
-      "version": "2.1.1",
-      "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.1.tgz",
-      "integrity": "sha512-9t7ZA7NGGK8ckelF0PQCfcxIUzs1Md5rrO6U/c+FIQNanea5UZC0wqKXH4vHBccmu4ZJgZ2idtPeW7+Q2npOEA==",
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.2.tgz",
+      "integrity": "sha512-+wvgpDsrB1YqAMdEUCcnTlpfVBH7Vqn6A/NT3D8WVXFIaKMlErPIZT3oCIAVCOtarRpMtelZLqJeU3t7WY6X6g==",
       "dev": true,
       "dependencies": {
         "ajv": "^6.12.4",
@@ -1831,9 +1812,9 @@
       }
     },
     "node_modules/@eslint/eslintrc/node_modules/globals": {
-      "version": "13.20.0",
-      "resolved": "https://registry.npmjs.org/globals/-/globals-13.20.0.tgz",
-      "integrity": "sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==",
+      "version": "13.21.0",
+      "resolved": "https://registry.npmjs.org/globals/-/globals-13.21.0.tgz",
+      "integrity": "sha512-ybyme3s4yy/t/3s35bewwXKOf7cvzfreG2lH0lZl0JB7I4GxRP2ghxOK/Nb9EkRXdbBXZLfq/p/0W2JUONB/Gg==",
       "dev": true,
       "dependencies": {
         "type-fest": "^0.20.2"
@@ -1858,9 +1839,9 @@
       }
     },
     "node_modules/@eslint/js": {
-      "version": "8.46.0",
-      "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.46.0.tgz",
-      "integrity": "sha512-a8TLtmPi8xzPkCbp/OGFUo5yhRkHM2Ko9kOWP4znJr0WAhWyThaw3PnwX4vOTWOAMsV2uRt32PPDcEz63esSaA==",
+      "version": "8.47.0",
+      "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.47.0.tgz",
+      "integrity": "sha512-P6omY1zv5MItm93kLM8s2vr1HICJH8v0dvddDhysbIuZ+vcjOHg5Zbkf1mTkcmi2JA9oBG2anOkRnW8WJTS8Og==",
       "dev": true,
       "engines": {
         "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
@@ -2053,13 +2034,13 @@
       "dev": true
     },
     "node_modules/@playcanvas/eslint-config": {
-      "version": "1.5.0",
-      "resolved": "https://registry.npmjs.org/@playcanvas/eslint-config/-/eslint-config-1.5.0.tgz",
-      "integrity": "sha512-V0ROXh9EbzWMfrdAGo38w71Bb1/sH3+p1JoFqNPn+XV8KmF+yQgO0WIw3oZOI++UFDbRz0J2APA/U/0cg9GU5A==",
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/@playcanvas/eslint-config/-/eslint-config-1.6.0.tgz",
+      "integrity": "sha512-YpOc3v61+xAZkKtHzXxBjU1YBQ2Gx6zGV3vlwZr7/ZqYmXYD8GLakgm4iI56JmYrqqpxZy1V0E7dloCX68zqPA==",
       "dev": true,
       "dependencies": {
-        "eslint-plugin-import": "^2.27.5",
-        "eslint-plugin-jsdoc": "^44.0.2"
+        "eslint-plugin-import": "^2.28.0",
+        "eslint-plugin-jsdoc": "^46.4.6"
       },
       "peerDependencies": {
         "eslint": ">= 4"
@@ -2170,9 +2151,9 @@
       }
     },
     "node_modules/@rollup/pluginutils": {
-      "version": "5.0.2",
-      "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.0.2.tgz",
-      "integrity": "sha512-pTd9rIsP92h+B6wWwFbW8RkZv4hiR/xKsqre4SIuAOaOEQRxi0lqLke9k2/7WegC85GgUs9pjmOjCUi3In4vwA==",
+      "version": "5.0.3",
+      "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.0.3.tgz",
+      "integrity": "sha512-hfllNN4a80rwNQ9QCxhxuHCGHMAvabXqxNdaChUSSadMre7t4iEUI6fFAhBOn/eIYTgYVhBv7vCLsAJ4u3lf3g==",
       "dev": true,
       "dependencies": {
         "@types/estree": "^1.0.0",
@@ -2308,6 +2289,12 @@
       "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==",
       "dev": true
     },
+    "node_modules/@types/webxr": {
+      "version": "0.5.3",
+      "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.3.tgz",
+      "integrity": "sha512-orrXqFCuuRE5hMuwLZhPaQrukAZxpkMzNJHmKCNJ16XT5yiR5iNBLNdn+xONbfzU2XAXTZR0GA2R99ciUd/2hg==",
+      "dev": true
+    },
     "node_modules/@webgpu/types": {
       "version": "0.1.34",
       "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.34.tgz",
@@ -3247,9 +3234,9 @@
       "dev": true
     },
     "node_modules/comment-parser": {
-      "version": "1.3.1",
-      "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.3.1.tgz",
-      "integrity": "sha512-B52sN2VNghyq5ofvUsqZjmk6YkihBX5vMSChmSK9v4ShjKf3Vk5Xcmgpw4o+iIgtrnM/u5FiMpz9VKb8lpBveA==",
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.0.tgz",
+      "integrity": "sha512-QLyTNiZ2KDOibvFPlZ6ZngVsZ/0gYnE6uTXi5aoDg8ed3AkJAz4sEje3Y8a29hQ1s6A99MZXe47fLAXQ1rTqaw==",
       "dev": true,
       "engines": {
         "node": ">= 12.0.0"
@@ -3783,15 +3770,15 @@
       }
     },
     "node_modules/eslint": {
-      "version": "8.46.0",
-      "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.46.0.tgz",
-      "integrity": "sha512-cIO74PvbW0qU8e0mIvk5IV3ToWdCq5FYG6gWPHHkx6gNdjlbAYvtfHmlCMXxjcoVaIdwy/IAt3+mDkZkfvb2Dg==",
+      "version": "8.47.0",
+      "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.47.0.tgz",
+      "integrity": "sha512-spUQWrdPt+pRVP1TTJLmfRNJJHHZryFmptzcafwSvHsceV81djHOdnEeDmkdotZyLNjDhrOasNK8nikkoG1O8Q==",
       "dev": true,
       "dependencies": {
         "@eslint-community/eslint-utils": "^4.2.0",
         "@eslint-community/regexpp": "^4.6.1",
-        "@eslint/eslintrc": "^2.1.1",
-        "@eslint/js": "^8.46.0",
+        "@eslint/eslintrc": "^2.1.2",
+        "@eslint/js": "^8.47.0",
         "@humanwhocodes/config-array": "^0.11.10",
         "@humanwhocodes/module-importer": "^1.0.1",
         "@nodelib/fs.walk": "^1.2.8",
@@ -3802,7 +3789,7 @@
         "doctrine": "^3.0.0",
         "escape-string-regexp": "^4.0.0",
         "eslint-scope": "^7.2.2",
-        "eslint-visitor-keys": "^3.4.2",
+        "eslint-visitor-keys": "^3.4.3",
         "espree": "^9.6.1",
         "esquery": "^1.4.2",
         "esutils": "^2.0.2",
@@ -3936,18 +3923,19 @@
       }
     },
     "node_modules/eslint-plugin-jsdoc": {
-      "version": "44.2.7",
-      "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-44.2.7.tgz",
-      "integrity": "sha512-PcAJO7Wh4xIHPT+StBRpEbWgwCpIrYk75zL31RMbduVVHpgiy3Y8aXQ6pdbRJOq0fxHuepWSEAve8ZrPWTSKRg==",
+      "version": "46.4.6",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-46.4.6.tgz",
+      "integrity": "sha512-z4SWYnJfOqftZI+b3RM9AtWL1vF/sLWE/LlO9yOKDof9yN2+n3zOdOJTGX/pRE/xnPsooOLG2Rq6e4d+XW3lNw==",
       "dev": true,
       "dependencies": {
-        "@es-joy/jsdoccomment": "~0.39.4",
+        "@es-joy/jsdoccomment": "~0.40.1",
         "are-docs-informative": "^0.0.2",
-        "comment-parser": "1.3.1",
+        "comment-parser": "1.4.0",
         "debug": "^4.3.4",
         "escape-string-regexp": "^4.0.0",
         "esquery": "^1.5.0",
-        "semver": "^7.5.1",
+        "is-builtin-module": "^3.2.1",
+        "semver": "^7.5.4",
         "spdx-expression-parse": "^3.0.1"
       },
       "engines": {
@@ -4102,9 +4090,9 @@
       }
     },
     "node_modules/eslint/node_modules/eslint-visitor-keys": {
-      "version": "3.4.2",
-      "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.2.tgz",
-      "integrity": "sha512-8drBzUEyZ2llkpCA67iYrgEssKDUu68V8ChqqOfFupIaG/LCVPUT+CoGJpT77zJprs4T/W7p07LP7zAIMuweVw==",
+      "version": "3.4.3",
+      "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+      "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
       "dev": true,
       "engines": {
         "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
@@ -4188,9 +4176,9 @@
       }
     },
     "node_modules/espree/node_modules/eslint-visitor-keys": {
-      "version": "3.4.2",
-      "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.2.tgz",
-      "integrity": "sha512-8drBzUEyZ2llkpCA67iYrgEssKDUu68V8ChqqOfFupIaG/LCVPUT+CoGJpT77zJprs4T/W7p07LP7zAIMuweVw==",
+      "version": "3.4.3",
+      "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+      "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
       "dev": true,
       "engines": {
         "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
@@ -6944,15 +6932,15 @@
       }
     },
     "node_modules/regenerator-runtime": {
-      "version": "0.13.11",
-      "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
-      "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
+      "version": "0.14.0",
+      "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz",
+      "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==",
       "dev": true
     },
     "node_modules/regenerator-transform": {
-      "version": "0.15.1",
-      "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.1.tgz",
-      "integrity": "sha512-knzmNAcuyxV+gQCufkYcvOqX/qIIfHLv0u5x79kRxuGojfYVky1f15TzZEu2Avte8QGepvUNTnLskf8E6X6Vyg==",
+      "version": "0.15.2",
+      "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.2.tgz",
+      "integrity": "sha512-hfMp2BoF0qOk3uc5V20ALGDS2ddjQaLrdl7xrGXvAIow7qeWRM2VA2HuCHkUKk9slq3VwEwLNK3DFBqDfPGYtg==",
       "dev": true,
       "dependencies": {
         "@babel/runtime": "^7.8.4"
@@ -7126,9 +7114,9 @@
       }
     },
     "node_modules/rollup": {
-      "version": "3.27.0",
-      "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.27.0.tgz",
-      "integrity": "sha512-aOltLCrYZ0FhJDm7fCqwTjIUEVjWjcydKBV/Zeid6Mn8BWgDCUBBWT5beM5ieForYNo/1ZHuGJdka26kvQ3Gzg==",
+      "version": "3.28.0",
+      "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.28.0.tgz",
+      "integrity": "sha512-d7zhvo1OUY2SXSM6pfNjgD5+d0Nz87CUp4mt8l/GgVP3oBsPwzNvSzyu1me6BSG9JIgWNTVcafIXBIyM8yQ3yw==",
       "dev": true,
       "bin": {
         "rollup": "dist/bin/rollup"
@@ -7142,24 +7130,24 @@
       }
     },
     "node_modules/rollup-plugin-dts": {
-      "version": "5.3.0",
-      "resolved": "https://registry.npmjs.org/rollup-plugin-dts/-/rollup-plugin-dts-5.3.0.tgz",
-      "integrity": "sha512-8FXp0ZkyZj1iU5klkIJYLjIq/YZSwBoERu33QBDxm/1yw5UU4txrEtcmMkrq+ZiKu3Q4qvPCNqc3ovX6rjqzbQ==",
+      "version": "5.3.1",
+      "resolved": "https://registry.npmjs.org/rollup-plugin-dts/-/rollup-plugin-dts-5.3.1.tgz",
+      "integrity": "sha512-gusMi+Z4gY/JaEQeXnB0RUdU82h1kF0WYzCWgVmV4p3hWXqelaKuCvcJawfeg+EKn2T1Ie+YWF2OiN1/L8bTVg==",
       "dev": true,
       "dependencies": {
-        "magic-string": "^0.30.0"
+        "magic-string": "^0.30.2"
       },
       "engines": {
-        "node": ">=v14"
+        "node": ">=v14.21.3"
       },
       "funding": {
         "url": "https://github.com/sponsors/Swatinem"
       },
       "optionalDependencies": {
-        "@babel/code-frame": "^7.18.6"
+        "@babel/code-frame": "^7.22.5"
       },
       "peerDependencies": {
-        "rollup": "^3.0.0",
+        "rollup": "^3.0",
         "typescript": "^4.1 || ^5.0"
       }
     },
diff --git a/package.json b/package.json
index 767f9659a..066a568d0 100644
--- a/package.json
+++ b/package.json
@@ -91,21 +91,22 @@
     "README-zh.md"
   ],
   "devDependencies": {
-    "@babel/core": "^7.22.9",
-    "@babel/eslint-parser": "^7.22.9",
-    "@babel/preset-env": "^7.22.9",
+    "@babel/core": "^7.22.10",
+    "@babel/eslint-parser": "^7.22.10",
+    "@babel/preset-env": "^7.22.10",
     "@playcanvas/canvas-mock": "^1.0.1",
-    "@playcanvas/eslint-config": "^1.5.0",
+    "@playcanvas/eslint-config": "^1.6.0",
     "@playcanvas/jsdoc-template": "^1.1.1",
     "@rollup/plugin-babel": "^6.0.3",
     "@rollup/plugin-node-resolve": "^15.1.0",
     "@rollup/plugin-strip": "^3.0.2",
     "@rollup/plugin-terser": "^0.4.3",
-    "@rollup/pluginutils": "^5.0.2",
+    "@rollup/pluginutils": "^5.0.3",
+    "@types/webxr": "^0.5.3",
     "@webgpu/types": "^0.1.34",
     "c8": "^8.0.0",
     "chai": "^4.3.7",
-    "eslint": "^8.45.0",
+    "eslint": "^8.47.0",
     "fflate": "^0.8.0",
     "jsdoc": "^4.0.2",
     "jsdoc-tsimport-plugin": "^1.0.5",
@@ -114,8 +115,8 @@
     "karma-mocha": "2.0.1",
     "karma-spec-reporter": "^0.0.36",
     "mocha": "^10.2.0",
-    "rollup": "^3.26.3",
-    "rollup-plugin-dts": "^5.3.0",
+    "rollup": "^3.28.0",
+    "rollup-plugin-dts": "^5.3.1",
     "rollup-plugin-jscc": "2.0.0",
     "rollup-plugin-visualizer": "^5.9.2",
     "serve": "^14.2.0",
diff --git a/src/core/event-handle.js b/src/core/event-handle.js
new file mode 100644
index 000000000..c887485a5
--- /dev/null
+++ b/src/core/event-handle.js
@@ -0,0 +1,116 @@
+import { Debug } from '../core/debug.js';
+
+/**
+ * Event Handle that is created by {@link EventHandler} and can be used for easier event removal and management.
+ * @example
+ * const evt = obj.on('test', (a, b) => {
+ *     console.log(a + b);
+ * });
+ * obj.fire('test');
+ *
+ * evt.off(); // easy way to remove this event
+ * obj.fire('test'); // this will not trigger an event
+ * @example
+ * // store an array of event handles
+ * let events = [ ];
+ *
+ * events.push(objA.on('testA', () => { }));
+ * events.push(objB.on('testB', () => { }));
+ *
+ * // when needed, remove all events
+ * events.forEach((evt) => {
+ *     evt.off();
+ * });
+ * events = [ ];
+ */
+class EventHandle {
+    /**
+     * @type {import('./event-handler.js').EventHandler}
+     * @private
+     */
+    handler;
+
+    /**
+     * @type {string}
+     * @private
+     */
+    name;
+
+    /**
+     * @type {import('./event-handler.js').HandleEventCallback}
+     * @ignore
+     */
+    callback;
+
+    /**
+     * @type {object}
+     * @ignore
+     */
+    scope;
+
+    /**
+     * @type {boolean}
+     * @ignore
+     */
+    _once;
+
+    /**
+     * True if event has been removed.
+     * @type {boolean}
+     * @private
+     */
+    _removed = false;
+
+    /**
+     * @param {import('./event-handler.js').EventHandler} handler - source object of the event.
+     * @param {string} name - Name of the event.
+     * @param {import('./event-handler.js').HandleEventCallback} callback - Function that is called when event is fired.
+     * @param {object} scope - Object that is used as `this` when event is fired.
+     * @param {boolean} [once] - If this is a single event and will be removed after event is fired.
+     */
+    constructor(handler, name, callback, scope, once = false) {
+        this.handler = handler;
+        this.name = name;
+        this.callback = callback;
+        this.scope = scope;
+        this._once = once;
+    }
+
+    /**
+     * Remove this event from its handler.
+     */
+    off() {
+        if (this._removed) return;
+        this.handler.off(this.name, this.callback, this.scope);
+    }
+
+    on(name, callback, scope = this) {
+        Debug.deprecated('Using chaining with EventHandler.on is deprecated, subscribe to an event from EventHandler directly instead.');
+        return this.handler._addCallback(name, callback, scope, false);
+    }
+
+    once(name, callback, scope = this) {
+        Debug.deprecated('Using chaning with EventHandler.once is deprecated, subscribe to an event from EventHandler directly instead.');
+        return this.handler._addCallback(name, callback, scope, true);
+    }
+
+    /**
+     * Mark if event has been removed.
+     * @type {boolean}
+     * @internal
+     */
+    set removed(value) {
+        if (!value) return;
+        this._removed = true;
+    }
+
+    /**
+     * True if event has been removed.
+     * @type {boolean}
+     */
+    get removed() {
+        return this._removed;
+    }
+}
+
+export { EventHandle };
diff --git a/src/core/event-handler.js b/src/core/event-handler.js
index e47d6d0ba..aabdee7b2 100644
--- a/src/core/event-handler.js
+++ b/src/core/event-handler.js
@@ -1,3 +1,5 @@
+import { EventHandle } from './event-handle.js';
+
 /**
  * Callback used by {@link EventHandler} functions. Note the callback is limited to 8 arguments.
  *
@@ -29,13 +31,13 @@
  */
 class EventHandler {
     /**
-     * @type {Map<string,Array<object>>}
+     * @type {Map<string,Array<EventHandle>>}
      * @private
      */
     _callbacks = new Map();
 
     /**
-     * @type {Map<string,Array<object>>}
+     * @type {Map<string,Array<EventHandle>>}
      * @private
      */
     _callbackActive = new Map();
@@ -58,11 +60,14 @@ class EventHandler {
      * @param {object} scope - Object to use as 'this' when the event is fired, defaults to
      * current this.
      * @param {boolean} once - If true, the callback will be unbound after being fired once.
+     * @returns {EventHandle} Created {@link EventHandle}.
      * @ignore
      */
     _addCallback(name, callback, scope, once) {
+        // #if _DEBUG
         if (!name || typeof name !== 'string' || !callback)
-            return;
+            console.warn(`EventHandler: subscribing to an event (${name}) with missing arguments`, callback);
+        // #endif
 
         if (!this._callbacks.has(name))
             this._callbacks.set(name, []);
@@ -76,11 +81,9 @@ class EventHandler {
             }
         }
 
-        this._callbacks.get(name).push({
-            callback: callback,
-            scope: scope,
-            once: once
-        });
+        const evt = new EventHandle(this, name, callback, scope, once);
+        this._callbacks.get(name).push(evt);
+        return evt;
     }
 
     /**
@@ -91,16 +94,21 @@ class EventHandler {
      * the callback is limited to 8 arguments.
      * @param {object} [scope] - Object to use as 'this' when the event is fired, defaults to
      * current this.
-     * @returns {EventHandler} Self for chaining.
+     * @returns {EventHandle} Can be used for removing event in the future.
      * @example
      * obj.on('test', function (a, b) {
      *     console.log(a + b);
      * });
      * obj.fire('test', 1, 2); // prints 3 to the console
+     * @example
+     * const evt = obj.on('test', function (a, b) {
+     *     console.log(a + b);
+     * });
+     * // some time later
+     * evt.off();
      */
     on(name, callback, scope = this) {
-        this._addCallback(name, callback, scope, false);
-        return this;
+        return this._addCallback(name, callback, scope, false);
     }
 
     /**
@@ -111,7 +119,7 @@ class EventHandler {
      * the callback is limited to 8 arguments.
      * @param {object} [scope] - Object to use as 'this' when the event is fired, defaults to
      * current this.
-     * @returns {EventHandler} Self for chaining.
+     * @returns {EventHandle} - can be used for removing event in the future.
      * @example
      * obj.once('test', function (a, b) {
      *     console.log(a + b);
@@ -120,8 +128,7 @@ class EventHandler {
      * obj.fire('test', 1, 2); // not going to get handled
      */
     once(name, callback, scope = this) {
-        this._addCallback(name, callback, scope, true);
-        return this;
+        return this._addCallback(name, callback, scope, true);
     }
 
     /**
@@ -165,33 +172,41 @@ class EventHandler {
 
         if (!name) {
             // remove all events
+            for (const callbacks of this._callbacks.values()) {
+                for (let i = 0; i < callbacks.length; i++) {
+                    callbacks[i].removed = true;
+                }
+            }
             this._callbacks.clear();
         } else if (!callback) {
             // remove all events of a specific name
-            if (this._callbacks.has(name))
+            const callbacks = this._callbacks.get(name);
+            if (callbacks) {
+                for (let i = 0; i < callbacks.length; i++) {
+                    callbacks[i].removed = true;
+                }
                 this._callbacks.delete(name);
+            }
         } else {
-            const events = this._callbacks.get(name);
-            if (!events)
+            const callbacks = this._callbacks.get(name);
+            if (!callbacks)
                 return this;
 
-            let count = events.length;
-
-            for (let i = 0; i < count; i++) {
+            for (let i = 0; i < callbacks.length; i++) {
                 // remove all events with a specific name and a callback
-                if (events[i].callback !== callback)
+                if (callbacks[i].callback !== callback)
                     continue;
 
                 // could be a specific scope as well
-                if (scope && events[i].scope !== scope)
+                if (scope && callbacks[i].scope !== scope)
                     continue;
 
-                events[i--] = events[--count];
+                callbacks[i].removed = true;
+                callbacks.splice(i, 1);
+                i--;
             }
 
-            events.length = count;
-
-            if (events.length === 0)
+            if (callbacks.length === 0)
                 this._callbacks.delete(name);
         }
 
@@ -237,9 +252,11 @@ class EventHandler {
         // eslint-disable-next-line no-unmodified-loop-condition
         for (let i = 0; (callbacks || this._callbackActive.get(name)) && (i < (callbacks || this._callbackActive.get(name)).length); i++) {
             const evt = (callbacks || this._callbackActive.get(name))[i];
+            if (!evt.callback) continue;
+
             evt.callback.call(evt.scope, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8);
 
-            if (evt.once) {
+            if (evt._once) {
                 // check that callback still exists because user may have unsubscribed in the event handler
                 const existingCallback = this._callbacks.get(name);
                 const ind = existingCallback ? existingCallback.indexOf(evt) : -1;
@@ -250,6 +267,7 @@ class EventHandler {
 
                     const callbacks = this._callbacks.get(name);
                     if (!callbacks) continue;
+                    callbacks[ind].removed = true;
                     callbacks.splice(ind, 1);
 
                     if (callbacks.length === 0)
diff --git a/src/core/math/curve-evaluator.js b/src/core/math/curve-evaluator.js
index ee7973352..bad84adb3 100644
--- a/src/core/math/curve-evaluator.js
+++ b/src/core/math/curve-evaluator.js
@@ -47,7 +47,7 @@ class CurveEvaluator {
      * changed since the last evaluation.
      *
      * @param {number} time - Time to evaluate the curve at.
-     * @param {boolean} [forceReset=false] - Force reset of the curve.
+     * @param {boolean} [forceReset] - Force reset of the curve.
      * @returns {number} The evaluated value.
      */
     evaluate(time, forceReset = false) {
diff --git a/src/core/math/mat3.js b/src/core/math/mat3.js
index ba8ce68c6..60b1d0976 100644
--- a/src/core/math/mat3.js
+++ b/src/core/math/mat3.js
@@ -177,6 +177,7 @@ class Mat3 {
     /**
      * Generates the transpose of the specified 3x3 matrix.
      *
+     * @param {Mat3} [src] - The matrix to transpose. If not set, the matrix is transposed in-place.
      * @returns {Mat3} Self for chaining.
      * @example
      * const m = new pc.Mat3();
@@ -184,13 +185,26 @@ class Mat3 {
      * // Transpose in place
      * m.transpose();
      */
-    transpose() {
-        const m = this.data;
-
-        let tmp;
-        tmp = m[1]; m[1] = m[3]; m[3] = tmp;
-        tmp = m[2]; m[2] = m[6]; m[6] = tmp;
-        tmp = m[5]; m[5] = m[7]; m[7] = tmp;
+    transpose(src = this) {
+        const s = src.data;
+        const t = this.data;
+
+        if (s === t) {
+            let tmp;
+            tmp = s[1]; t[1] = s[3]; t[3] = tmp;
+            tmp = s[2]; t[2] = s[6]; t[6] = tmp;
+            tmp = s[5]; t[5] = s[7]; t[7] = tmp;
+        } else {
+            t[0] = s[0];
+            t[1] = s[3];
+            t[2] = s[6];
+            t[3] = s[1];
+            t[4] = s[4];
+            t[5] = s[7];
+            t[6] = s[2];
+            t[7] = s[5];
+            t[8] = s[8];
+        }
 
         return this;
     }
@@ -220,6 +234,60 @@ class Mat3 {
         return this;
     }
 
+    /**
+     * Set the matrix to the inverse of the specified 4x4 matrix.
+     *
+     * @param {import('./mat4.js').Mat4} src - The 4x4 matrix to invert.
+     * @returns {Mat3} Self for chaining.
+     *
+     * @ignore
+     */
+    invertMat4(src) {
+        const s = src.data;
+
+        const a0 = s[0];
+        const a1 = s[1];
+        const a2 = s[2];
+
+        const a4 = s[4];
+        const a5 = s[5];
+        const a6 = s[6];
+
+        const a8 = s[8];
+        const a9 = s[9];
+        const a10 = s[10];
+
+        const b11 =  a10 * a5 - a6 * a9;
+        const b21 = -a10 * a1 + a2 * a9;
+        const b31 =  a6  * a1 - a2 * a5;
+        const b12 = -a10 * a4 + a6 * a8;
+        const b22 =  a10 * a0 - a2 * a8;
+        const b32 = -a6  * a0 + a2 * a4;
+        const b13 =  a9  * a4 - a5 * a8;
+        const b23 = -a9  * a0 + a1 * a8;
+        const b33 =  a5  * a0 - a1 * a4;
+
+        const det =  a0 * b11 + a1 * b12 + a2 * b13;
+        if (det === 0) {
+            this.setIdentity();
+        } else {
+            const invDet = 1 / det;
+            const t = this.data;
+
+            t[0] = b11 * invDet;
+            t[1] = b21 * invDet;
+            t[2] = b31 * invDet;
+            t[3] = b12 * invDet;
+            t[4] = b22 * invDet;
+            t[5] = b32 * invDet;
+            t[6] = b13 * invDet;
+            t[7] = b23 * invDet;
+            t[8] = b33 * invDet;
+        }
+
+        return this;
+    }
+
     /**
      * Transforms a 3-dimensional vector by a 3x3 matrix.
      *
diff --git a/src/core/math/mat4.js b/src/core/math/mat4.js
index 9ef7b21b6..c99e7ca02 100644
--- a/src/core/math/mat4.js
+++ b/src/core/math/mat4.js
@@ -574,8 +574,8 @@ class Mat4 {
      * (width / height).
      * @param {number} znear - The near clip plane in eye coordinates.
      * @param {number} zfar - The far clip plane in eye coordinates.
-     * @param {boolean} [fovIsHorizontal=false] - Set to true to treat the fov as horizontal
-     * (x-axis) and false for vertical (y-axis). Defaults to false.
+     * @param {boolean} [fovIsHorizontal] - Set to true to treat the fov as horizontal (x-axis) and
+     * false for vertical (y-axis). Defaults to false.
      * @returns {Mat4} Self for chaining.
      * @example
      * // Create a 4x4 perspective projection matrix
@@ -816,8 +816,9 @@ class Mat4 {
     }
 
     /**
-     * Sets the specified matrix to its inverse.
+     * Sets the matrix to the inverse of a source matrix.
      *
+     * @param {Mat4} [src] - The matrix to invert. If not set, the matrix is inverted in-place.
      * @returns {Mat4} Self for chaining.
      * @example
      * // Create a 4x4 rotation matrix of 180 degrees around the y-axis
@@ -826,25 +827,25 @@ class Mat4 {
      * // Invert in place
      * rot.invert();
      */
-    invert() {
-        const m = this.data;
-
-        const a00 = m[0];
-        const a01 = m[1];
-        const a02 = m[2];
-        const a03 = m[3];
-        const a10 = m[4];
-        const a11 = m[5];
-        const a12 = m[6];
-        const a13 = m[7];
-        const a20 = m[8];
-        const a21 = m[9];
-        const a22 = m[10];
-        const a23 = m[11];
-        const a30 = m[12];
-        const a31 = m[13];
-        const a32 = m[14];
-        const a33 = m[15];
+    invert(src = this) {
+        const s = src.data;
+
+        const a00 = s[0];
+        const a01 = s[1];
+        const a02 = s[2];
+        const a03 = s[3];
+        const a10 = s[4];
+        const a11 = s[5];
+        const a12 = s[6];
+        const a13 = s[7];
+        const a20 = s[8];
+        const a21 = s[9];
+        const a22 = s[10];
+        const a23 = s[11];
+        const a30 = s[12];
+        const a31 = s[13];
+        const a32 = s[14];
+        const a33 = s[15];
 
         const b00 = a00 * a11 - a01 * a10;
         const b01 = a00 * a12 - a02 * a10;
@@ -864,23 +865,24 @@ class Mat4 {
             this.setIdentity();
         } else {
             const invDet = 1 / det;
-
-            m[0] = (a11 * b11 - a12 * b10 + a13 * b09) * invDet;
-            m[1] = (-a01 * b11 + a02 * b10 - a03 * b09) * invDet;
-            m[2] = (a31 * b05 - a32 * b04 + a33 * b03) * invDet;
-            m[3] = (-a21 * b05 + a22 * b04 - a23 * b03) * invDet;
-            m[4] = (-a10 * b11 + a12 * b08 - a13 * b07) * invDet;
-            m[5] = (a00 * b11 - a02 * b08 + a03 * b07) * invDet;
-            m[6] = (-a30 * b05 + a32 * b02 - a33 * b01) * invDet;
-            m[7] = (a20 * b05 - a22 * b02 + a23 * b01) * invDet;
-            m[8] = (a10 * b10 - a11 * b08 + a13 * b06) * invDet;
-            m[9] = (-a00 * b10 + a01 * b08 - a03 * b06) * invDet;
-            m[10] = (a30 * b04 - a31 * b02 + a33 * b00) * invDet;
-            m[11] = (-a20 * b04 + a21 * b02 - a23 * b00) * invDet;
-            m[12] = (-a10 * b09 + a11 * b07 - a12 * b06) * invDet;
-            m[13] = (a00 * b09 - a01 * b07 + a02 * b06) * invDet;
-            m[14] = (-a30 * b03 + a31 * b01 - a32 * b00) * invDet;
-            m[15] = (a20 * b03 - a21 * b01 + a22 * b00) * invDet;
+            const t = this.data;
+
+            t[0] = (a11 * b11 - a12 * b10 + a13 * b09) * invDet;
+            t[1] = (-a01 * b11 + a02 * b10 - a03 * b09) * invDet;
+            t[2] = (a31 * b05 - a32 * b04 + a33 * b03) * invDet;
+            t[3] = (-a21 * b05 + a22 * b04 - a23 * b03) * invDet;
+            t[4] = (-a10 * b11 + a12 * b08 - a13 * b07) * invDet;
+            t[5] = (a00 * b11 - a02 * b08 + a03 * b07) * invDet;
+            t[6] = (-a30 * b05 + a32 * b02 - a33 * b01) * invDet;
+            t[7] = (a20 * b05 - a22 * b02 + a23 * b01) * invDet;
+            t[8] = (a10 * b10 - a11 * b08 + a13 * b06) * invDet;
+            t[9] = (-a00 * b10 + a01 * b08 - a03 * b06) * invDet;
+            t[10] = (a30 * b04 - a31 * b02 + a33 * b00) * invDet;
+            t[11] = (-a20 * b04 + a21 * b02 - a23 * b00) * invDet;
+            t[12] = (-a10 * b09 + a11 * b07 - a12 * b06) * invDet;
+            t[13] = (a00 * b09 - a01 * b07 + a02 * b06) * invDet;
+            t[14] = (-a30 * b03 + a31 * b01 - a32 * b00) * invDet;
+            t[15] = (a20 * b03 - a21 * b01 + a22 * b00) * invDet;
         }
 
         return this;
@@ -1011,8 +1013,9 @@ class Mat4 {
     }
 
     /**
-     * Sets the specified matrix to its transpose.
+     * Sets the matrix to the transpose of a source matrix.
      *
+     * @param {Mat4} [src] - The matrix to transpose. If not set, the matrix is transposed in-place.
      * @returns {Mat4} Self for chaining.
      * @example
      * const m = new pc.Mat4();
@@ -1020,80 +1023,55 @@ class Mat4 {
      * // Transpose in place
      * m.transpose();
      */
-    transpose() {
-        let tmp;
-        const m = this.data;
-
-        tmp = m[1];
-        m[1] = m[4];
-        m[4] = tmp;
+    transpose(src = this) {
+        const s = src.data;
+        const t = this.data;
 
-        tmp = m[2];
-        m[2] = m[8];
-        m[8] = tmp;
+        if (s === t) {
+            let tmp;
 
-        tmp = m[3];
-        m[3] = m[12];
-        m[12] = tmp;
+            tmp = s[1];
+            t[1] = s[4];
+            t[4] = tmp;
 
-        tmp = m[6];
-        m[6] = m[9];
-        m[9] = tmp;
+            tmp = s[2];
+            t[2] = s[8];
+            t[8] = tmp;
 
-        tmp = m[7];
-        m[7] = m[13];
-        m[13] = tmp;
+            tmp = s[3];
+            t[3] = s[12];
+            t[12] = tmp;
 
-        tmp = m[11];
-        m[11] = m[14];
-        m[14] = tmp;
+            tmp = s[6];
+            t[6] = s[9];
+            t[9] = tmp;
 
-        return this;
-    }
+            tmp = s[7];
+            t[7] = s[13];
+            t[13] = tmp;
 
-    invertTo3x3(res) {
-        const m = this.data;
-        const r = res.data;
-
-        const m0 = m[0];
-        const m1 = m[1];
-        const m2 = m[2];
-
-        const m4 = m[4];
-        const m5 = m[5];
-        const m6 = m[6];
-
-        const m8 = m[8];
-        const m9 = m[9];
-        const m10 = m[10];
-
-        const a11 =  m10 * m5 - m6 * m9;
-        const a21 = -m10 * m1 + m2 * m9;
-        const a31 =  m6  * m1 - m2 * m5;
-        const a12 = -m10 * m4 + m6 * m8;
-        const a22 =  m10 * m0 - m2 * m8;
-        const a32 = -m6  * m0 + m2 * m4;
-        const a13 =  m9  * m4 - m5 * m8;
-        const a23 = -m9  * m0 + m1 * m8;
-        const a33 =  m5  * m0 - m1 * m4;
-
-        const det =  m0 * a11 + m1 * a12 + m2 * a13;
-        if (det === 0) { // no inverse
-            return this;
+            tmp = s[11];
+            t[11] = s[14];
+            t[14] = tmp;
+        } else {
+            t[0] = s[0];
+            t[1] = s[4];
+            t[2] = s[8];
+            t[3] = s[12];
+            t[4] = s[1];
+            t[5] = s[5];
+            t[6] = s[9];
+            t[7] = s[13];
+            t[8] = s[2];
+            t[9] = s[6];
+            t[10] = s[10];
+            t[11] = s[14];
+            t[12] = s[3];
+            t[13] = s[7];
+            t[14] = s[11];
+            t[15] = s[15];
         }
 
-        const idet = 1 / det;
-
-        r[0] = idet * a11;
-        r[1] = idet * a21;
-        r[2] = idet * a31;
-        r[3] = idet * a12;
-        r[4] = idet * a22;
-        r[5] = idet * a32;
-        r[6] = idet * a13;
-        r[7] = idet * a23;
-        r[8] = idet * a33;
-
         return this;
     }
 
diff --git a/src/core/math/quat.js b/src/core/math/quat.js
index d6a698db3..b69305b95 100644
--- a/src/core/math/quat.js
+++ b/src/core/math/quat.js
@@ -120,7 +120,8 @@ class Quat {
      * Reports whether two quaternions are equal using an absolute error tolerance.
      *
      * @param {Quat} rhs - The quaternion to be compared against.
-     * @param {number} [epsilon=1e-6] - The maximum difference between each component of the two quaternions. Defaults to 1e-6.
+     * @param {number} [epsilon] - The maximum difference between each component of the two
+     * quaternions. Defaults to 1e-6.
      * @returns {boolean} True if the quaternions are equal and false otherwise.
      * @example
      * const a = new pc.Quat();
diff --git a/src/deprecated/deprecated.js b/src/deprecated/deprecated.js
index b3cbe6640..79358fc03 100644
--- a/src/deprecated/deprecated.js
+++ b/src/deprecated/deprecated.js
@@ -35,7 +35,7 @@ import {
     TEXTURETYPE_DEFAULT, TEXTURETYPE_RGBM, TEXTURETYPE_SWIZZLEGGGR,
     TYPE_INT8, TYPE_UINT8, TYPE_INT16, TYPE_UINT16, TYPE_INT32, TYPE_UINT32, TYPE_FLOAT32
 } from '../platform/graphics/constants.js';
-import { begin, end, fogCode, gammaCode, skinCode, tonemapCode } from '../scene/shader-lib/programs/common.js';
+import { ShaderGenerator } from '../scene/shader-lib/programs/shader-generator.js';
 import { drawQuadWithShader } from '../scene/graphics/quad-render-utils.js';
 import { shaderChunks } from '../scene/shader-lib/chunks/chunks.js';
 import { GraphicsDevice } from '../platform/graphics/graphics-device.js';
@@ -369,14 +369,14 @@ export function ContextCreationError(message) {
 ContextCreationError.prototype = Error.prototype;
 
 export const programlib = {
-    begin: begin,
+    begin: ShaderGenerator.begin,
     dummyFragmentCode: ShaderUtils.dummyFragmentCode,
-    end: end,
-    fogCode: fogCode,
-    gammaCode: gammaCode,
+    end: ShaderGenerator.end,
+    fogCode: ShaderGenerator.fogCode,
+    gammaCode: ShaderGenerator.gammaCode,
     precisionCode: ShaderUtils.precisionCode,
-    skinCode: skinCode,
-    tonemapCode: tonemapCode,
+    skinCode: ShaderGenerator.skinCode,
+    tonemapCode: ShaderGenerator.tonemapCode,
     versionCode: ShaderUtils.versionCode
 };
 
diff --git a/src/framework/app-base.js b/src/framework/app-base.js
index a87450287..db934cf51 100644
--- a/src/framework/app-base.js
+++ b/src/framework/app-base.js
@@ -91,6 +91,12 @@ class Progress {
  * @callback PreloadAppCallback
  */
 
+/**
+ * Gets the current application, if any.
+ *
+ * @type {AppBase|null}
+ * @ignore
+ */
 let app = null;
 
 /**
@@ -607,14 +613,6 @@ class AppBase extends EventHandler {
         this.tick = makeTick(this); // Circular linting issue as makeTick and Application reference each other
     }
 
-    /**
-     * @private
-     * @static
-     * @name app
-     * @type {AppBase|undefined}
-     * @description Gets the current application, if any.
-     */
-
     static _applications = {};
 
     /**
diff --git a/src/framework/components/camera/component.js b/src/framework/components/camera/component.js
index 423bed2cb..d61d47488 100644
--- a/src/framework/components/camera/component.js
+++ b/src/framework/components/camera/component.js
@@ -946,6 +946,7 @@ class CameraComponent extends Component {
      * @param {import('../../xr/xr-manager.js').XrErrorCallback} [options.callback] - Optional
      * callback function called once the session is started. The callback has one argument Error -
      * it is null if the XR session started successfully.
+     * @param {boolean} [options.anchors] - Optional boolean to attempt to enable {@link XrAnchors}.
      * @param {object} [options.depthSensing] - Optional object with depth sensing parameters to
      * attempt to enable {@link XrDepthSensing}.
      * @param {string} [options.depthSensing.usagePreference] - Optional usage preference for depth
diff --git a/src/framework/components/element/component.js b/src/framework/components/element/component.js
index 8c5dfd33b..86f82191d 100644
--- a/src/framework/components/element/component.js
+++ b/src/framework/components/element/component.js
@@ -1144,7 +1144,8 @@ class ElementComponent extends Component {
                 element._worldCornersDirty = true;
             }
 
-            return Entity.prototype._sync.call(this);
+            Entity.prototype._sync.call(this);
+            return;
         }
 
 
diff --git a/src/framework/components/particle-system/system.js b/src/framework/components/particle-system/system.js
index cd3c696a0..9341300a2 100644
--- a/src/framework/components/particle-system/system.js
+++ b/src/framework/components/particle-system/system.js
@@ -2,8 +2,6 @@ import { Curve } from '../../../core/math/curve.js';
 import { CurveSet } from '../../../core/math/curve-set.js';
 import { Vec3 } from '../../../core/math/vec3.js';
 
-import { LIGHTTYPE_DIRECTIONAL } from '../../../scene/constants.js';
-
 import { Asset } from '../../asset/asset.js';
 
 import { Component } from '../component.js';
@@ -205,6 +203,12 @@ class ParticleSystemComponentSystem extends ComponentSystem {
         const components = this.store;
         let numSteps;
         const stats = this.app.stats.particles;
+        const composition = this.app.scene.layers;
+
+        // disable light cube on all layers first
+        for (let i = 0; i < composition.layerList.length; i++) {
+            composition.layerList[i].requiresLightCube = false;
+        }
 
         for (const id in components) {
             if (components.hasOwnProperty(id)) {
@@ -216,36 +220,14 @@ class ParticleSystemComponentSystem extends ComponentSystem {
                     const emitter = entity.particlesystem.emitter;
                     if (!emitter?.meshInstance.visible) continue;
 
-                    // Bake ambient and directional lighting into one ambient cube
-                    // TODO: only do if lighting changed
-                    // TODO: don't do for every emitter
+                    // if emitter is using lighting, enable light cube on all layers it is assigned to
                     if (emitter.lighting) {
                         const layers = data.layers;
-                        let lightCube;
                         for (let i = 0; i < layers.length; i++) {
-                            const layer = this.app.scene.layers.getLayerById(layers[i]);
-                            if (!layer) continue;
-
-                            if (!layer._lightCube) {
-                                layer._lightCube = new Float32Array(6 * 3);
-                            }
-                            lightCube = layer._lightCube;
-                            for (let j = 0; j < 6; j++) {
-                                lightCube[j * 3] = this.app.scene.ambientLight.r;
-                                lightCube[j * 3 + 1] = this.app.scene.ambientLight.g;
-                                lightCube[j * 3 + 2] = this.app.scene.ambientLight.b;
-                            }
-                            const dirs = layer._splitLights[LIGHTTYPE_DIRECTIONAL];
-                            for (let j = 0; j < dirs.length; j++) {
-                                for (let c = 0; c < 6; c++) {
-                                    const weight = Math.max(emitter.lightCubeDir[c].dot(dirs[j]._direction), 0) * dirs[j]._intensity;
-                                    lightCube[c * 3] += dirs[j]._color.r * weight;
-                                    lightCube[c * 3 + 1] += dirs[j]._color.g * weight;
-                                    lightCube[c * 3 + 2] += dirs[j]._color.b * weight;
-                                }
-                            }
+                            const layer = composition.getLayerById(layers[i]);
+                            if (layer)
+                                layer.requiresLightCube = true;
                         }
-                        emitter.constantLightCube.setValue(lightCube); // ?
                     }
 
                     if (!data.paused) {
diff --git a/src/framework/components/sound/component.js b/src/framework/components/sound/component.js
index b3456a687..5989d9da7 100644
--- a/src/framework/components/sound/component.js
+++ b/src/framework/components/sound/component.js
@@ -344,19 +344,21 @@ class SoundComponent extends Component {
      *
      * @param {string} name - The name of the slot.
      * @param {object} [options] - Settings for the slot.
-     * @param {number} [options.volume=1] - The playback volume, between 0 and 1.
-     * @param {number} [options.pitch=1] - The relative pitch, default of 1, plays at normal pitch.
-     * @param {boolean} [options.loop=false] - If true the sound will restart when it reaches the end.
-     * @param {number} [options.startTime=0] - The start time from which the sound will start playing.
-     * @param {number} [options.duration=null] - The duration of the sound that the slot will play
-     * starting from startTime.
-     * @param {boolean} [options.overlap=false] - If true then sounds played from slot will be
-     * played independently of each other. Otherwise the slot will first stop the current sound
-     * before starting the new one.
-     * @param {boolean} [options.autoPlay=false] - If true the slot will start playing as soon as
-     * its audio asset is loaded.
-     * @param {number} [options.asset=null] - The asset id of the audio asset that is going to be
-     * played by this slot.
+     * @param {number} [options.volume] - The playback volume, between 0 and 1. Defaults to 1.
+     * @param {number} [options.pitch] - The relative pitch. Defaults to 1 (plays at normal pitch).
+     * @param {boolean} [options.loop] - If true the sound will restart when it reaches the end.
+     * Defaults to false.
+     * @param {number} [options.startTime] - The start time from which the sound will start playing.
+     * Defaults to 0 to start at the beginning.
+     * @param {number} [options.duration] - The duration of the sound that the slot will play
+     * starting from startTime. Defaults to `null` which means play to end of the sound.
+     * @param {boolean} [options.overlap] - If true then sounds played from slot will be played
+     * independently of each other. Otherwise the slot will first stop the current sound before
+     * starting the new one. Defaults to false.
+     * @param {boolean} [options.autoPlay] - If true the slot will start playing as soon as its
+     * audio asset is loaded. Defaults to false.
+     * @param {number} [options.asset] - The asset id of the audio asset that is going to be played
+     * by this slot.
      * @returns {SoundSlot|null} The new slot or null if the slot already exists.
      * @example
      * // get an asset by id
diff --git a/src/framework/components/sound/slot.js b/src/framework/components/sound/slot.js
index 4a6ed2f69..f2af18a0f 100644
--- a/src/framework/components/sound/slot.js
+++ b/src/framework/components/sound/slot.js
@@ -56,21 +56,20 @@ class SoundSlot extends EventHandler {
      * slot.
      * @param {string} [name] - The name of the slot. Defaults to 'Untitled'.
      * @param {object} [options] - Settings for the slot.
-     * @param {number} [options.volume=1] - The playback volume, between 0 and 1.
-     * @param {number} [options.pitch=1] - The relative pitch, default of 1, plays at normal pitch.
-     * @param {boolean} [options.loop=false] - If true the sound will restart when it reaches the
-     * end.
-     * @param {number} [options.startTime=0] - The start time from which the sound will start
+     * @param {number} [options.volume] - The playback volume, between 0 and 1.
+     * @param {number} [options.pitch] - The relative pitch, default of 1, plays at normal pitch.
+     * @param {boolean} [options.loop] - If true the sound will restart when it reaches the end.
+     * @param {number} [options.startTime] - The start time from which the sound will start
      * playing.
-     * @param {number} [options.duration=null] - The duration of the sound that the slot will play
+     * @param {number} [options.duration] - The duration of the sound that the slot will play
      * starting from startTime.
-     * @param {boolean} [options.overlap=false] - If true then sounds played from slot will be
-     * played independently of each other. Otherwise the slot will first stop the current sound
-     * before starting the new one.
-     * @param {boolean} [options.autoPlay=false] - If true the slot will start playing as soon as
-     * its audio asset is loaded.
-     * @param {number} [options.asset=null] - The asset id of the audio asset that is going to be
-     * played by this slot.
+     * @param {boolean} [options.overlap] - If true then sounds played from slot will be played
+     * independently of each other. Otherwise the slot will first stop the current sound before
+     * starting the new one.
+     * @param {boolean} [options.autoPlay] - If true the slot will start playing as soon as its
+     * audio asset is loaded.
+     * @param {number} [options.asset] - The asset id of the audio asset that is going to be played
+     * by this slot.
      */
     constructor(component, name = 'Untitled', options = {}) {
         super();
diff --git a/src/framework/handlers/basis-worker.js b/src/framework/handlers/basis-worker.js
index ae80e9db0..04a34a68d 100644
--- a/src/framework/handlers/basis-worker.js
+++ b/src/framework/handlers/basis-worker.js
@@ -182,6 +182,7 @@ function BasisWorker() {
                 // https://www.khronos.org/registry/webgl/extensions/rejected/WEBGL_compressed_texture_atc/
                 return true;
         }
+        return false;
     };
 
     const transcodeKTX2 = (url, data, options) => {
diff --git a/src/framework/handlers/font.js b/src/framework/handlers/font.js
index 5b3856d8b..0a61cb4ce 100644
--- a/src/framework/handlers/font.js
+++ b/src/framework/handlers/font.js
@@ -75,12 +75,14 @@ class FontHandler {
                 if (!err) {
                     const data = upgradeDataSchema(response);
                     self._loadTextures(url.load.replace('.json', '.png'), data, function (err, textures) {
-                        if (err) return callback(err);
-
-                        callback(null, {
-                            data: data,
-                            textures: textures
-                        });
+                        if (err) {
+                            callback(err);
+                        } else {
+                            callback(null, {
+                                data: data,
+                                textures: textures
+                            });
+                        }
                     });
                 } else {
                     callback(`Error loading font resource: ${url.original} [${err}]`);
@@ -110,7 +112,8 @@ class FontHandler {
 
                 if (err) {
                     error = err;
-                    return callback(err);
+                    callback(err);
+                    return;
                 }
 
                 texture.upload();
diff --git a/src/framework/handlers/scene-utils.js b/src/framework/handlers/scene-utils.js
index 24dc14762..7a277590c 100644
--- a/src/framework/handlers/scene-utils.js
+++ b/src/framework/handlers/scene-utils.js
@@ -1,17 +1,14 @@
 import { http } from '../../platform/net/http.js';
 
-const SceneUtils = {
+class SceneUtils {
     /**
-     * @private
-     * @function
-     * @static
-     * @name SceneUtils.load
-     * @description Loads the scene JSON file from a URL.
+     * Loads the scene JSON file from a URL.
+     *
      * @param {string} url - URL to scene JSON.
      * @param {number} maxRetries - Number of http load retry attempts.
      * @param {Function} callback - The callback to the JSON file is loaded.
      */
-    load: function (url, maxRetries, callback) {
+    static load(url, maxRetries, callback) {
         if (typeof url === 'string') {
             url = {
                 load: url,
@@ -40,6 +37,6 @@ const SceneUtils = {
             }
         });
     }
-};
+}
 
 export { SceneUtils };
diff --git a/src/framework/handlers/texture-atlas.js b/src/framework/handlers/texture-atlas.js
index 27a21ae6b..4ed7a6818 100644
--- a/src/framework/handlers/texture-atlas.js
+++ b/src/framework/handlers/texture-atlas.js
@@ -92,7 +92,7 @@ class TextureAtlasHandler {
                 }
             });
         } else {
-            return handler.load(url, callback);
+            handler.load(url, callback);
         }
     }
 
diff --git a/src/framework/lightmapper/lightmapper.js b/src/framework/lightmapper/lightmapper.js
index 8696718d1..393be53dd 100644
--- a/src/framework/lightmapper/lightmapper.js
+++ b/src/framework/lightmapper/lightmapper.js
@@ -836,7 +836,7 @@ class Lightmapper {
         light.visibleThisFrame = true;
     }
 
-    renderShadowMap(shadowMapRendered, casters, bakeLight) {
+    renderShadowMap(comp, shadowMapRendered, casters, bakeLight) {
 
         const light = bakeLight.light;
         const isClustered = this.scene.clusteredLightingEnabled;
@@ -849,9 +849,9 @@ class Lightmapper {
             }
 
             if (light.type === LIGHTTYPE_DIRECTIONAL) {
-                this.renderer._shadowRendererDirectional.cull(light, casters, this.camera);
+                this.renderer._shadowRendererDirectional.cull(light, comp, this.camera, casters);
             } else {
-                this.renderer._shadowRendererLocal.cull(light, casters);
+                this.renderer._shadowRendererLocal.cull(light, comp, casters);
             }
 
             const insideRenderPass = false;
@@ -910,6 +910,7 @@ class Lightmapper {
     bakeInternal(passCount, bakeNodes, allNodes) {
 
         const scene = this.scene;
+        const comp = scene.layers;
         const device = this.device;
         const clusteredLightingEnabled = scene.clusteredLightingEnabled;
 
@@ -917,7 +918,7 @@ class Lightmapper {
         this.setupScene();
 
         // update layer composition
-        scene.layers._update();
+        comp._update();
 
         // compute bounding boxes for nodes
         this.computeNodesBounds(bakeNodes);
@@ -927,7 +928,7 @@ class Lightmapper {
 
         // Collect bakeable lights, and also keep allLights along with their properties we change to restore them later
         const allLights = [], bakeLights = [];
-        this.prepareLightsToBake(scene.layers, allLights, bakeLights);
+        this.prepareLightsToBake(comp, allLights, bakeLights);
 
         // update transforms
         this.updateTransforms(allNodes);
@@ -1016,7 +1017,7 @@ class Lightmapper {
                     }
 
                     // render light shadow map needs to be rendered
-                    shadowMapRendered = this.renderShadowMap(shadowMapRendered, casters, bakeLight);
+                    shadowMapRendered = this.renderShadowMap(comp, shadowMapRendered, casters, bakeLight);
 
                     if (clusteredLightingEnabled) {
                         const clusterLights = lightArray[LIGHTTYPE_SPOT].concat(lightArray[LIGHTTYPE_OMNI]);
diff --git a/src/framework/xr/xr-anchor.js b/src/framework/xr/xr-anchor.js
new file mode 100644
index 000000000..3ed6cdf9a
--- /dev/null
+++ b/src/framework/xr/xr-anchor.js
@@ -0,0 +1,119 @@
+import { EventHandler } from '../../core/event-handler.js';
+
+import { Vec3 } from '../../core/math/vec3.js';
+import { Quat } from '../../core/math/quat.js';
+
+/**
+ * An anchor keeps track of a position and rotation that is fixed relative to the real world.
+ * This allows the application to adjust the location of the virtual objects placed in the
+ * scene in a way that helps with maintaining the illusion that the placed objects are really
+ * present in the user’s environment.
+ *
+ * @augments EventHandler
+ * @category XR
+ */
+class XrAnchor extends EventHandler {
+    /**
+     * @type {Vec3}
+     * @private
+     */
+    _position = new Vec3();
+
+    /**
+     * @type {Quat}
+     * @private
+     */
+    _rotation = new Quat();
+
+    /**
+     * @param {import('./xr-anchors.js').XrAnchors} anchors - Anchor manager.
+     * @param {object} xrAnchor - native XRAnchor object that is provided by WebXR API
+     * @hideconstructor
+     */
+    constructor(anchors, xrAnchor) {
+        super();
+
+        this._anchors = anchors;
+        this._xrAnchor = xrAnchor;
+    }
+
+    /**
+     * Fired when an {@link XrAnchor} is destroyed.
+     *
+     * @event XrAnchor#destroy
+     * @example
+     * // once anchor is destroyed
+     * anchor.once('destroy', function () {
+     *     // destroy its related entity
+     *     entity.destroy();
+     * });
+     */
+
+    /**
+     * Fired when an {@link XrAnchor}'s position and/or rotation is changed.
+     *
+     * @event XrAnchor#change
+     * @example
+     * anchor.on('change', function () {
+     *     // anchor has been updated
+     *     entity.setPosition(anchor.getPosition());
+     *     entity.setRotation(anchor.getRotation());
+     * });
+     */
+
+    /**
+     * Destroy an anchor.
+     */
+    destroy() {
+        if (!this._xrAnchor) return;
+        this._anchors._index.delete(this._xrAnchor);
+
+        const ind = this._anchors._list.indexOf(this);
+        if (ind !== -1) this._anchors._list.splice(ind, 1);
+
+        this._xrAnchor.delete();
+        this._xrAnchor = null;
+
+        this.fire('destroy');
+        this._anchors.fire('destroy', this);
+    }
+
+    /**
+     * @param {*} frame - XRFrame from requestAnimationFrame callback.
+     * @ignore
+     */
+    update(frame) {
+        if (!this._xrAnchor)
+            return;
+
+        const pose = frame.getPose(this._xrAnchor.anchorSpace, this._anchors.manager._referenceSpace);
+        if (pose) {
+            if (this._position.equals(pose.transform.position) && this._rotation.equals(pose.transform.orientation))
+                return;
+
+            this._position.copy(pose.transform.position);
+            this._rotation.copy(pose.transform.orientation);
+            this.fire('change');
+        }
+    }
+
+    /**
+     * Get the world space position of an anchor.
+     *
+     * @returns {Vec3} The world space position of an anchor.
+     */
+    getPosition() {
+        return this._position;
+    }
+
+    /**
+     * Get the world space rotation of an anchor.
+     *
+     * @returns {Quat} The world space rotation of an anchor.
+     */
+    getRotation() {
+        return this._rotation;
+    }
+}
+
+export { XrAnchor };
diff --git a/src/framework/xr/xr-anchors.js b/src/framework/xr/xr-anchors.js
new file mode 100644
index 000000000..c273878a1
--- /dev/null
+++ b/src/framework/xr/xr-anchors.js
@@ -0,0 +1,236 @@
+import { EventHandler } from '../../core/event-handler.js';
+import { platform } from '../../core/platform.js';
+import { XrAnchor } from './xr-anchor.js';
+
+/**
+ * Callback used by {@link XrAnchors#create}.
+ *
+ * @callback XrAnchorCreate
+ * @param {Error|null} err - The Error object if failed to create an anchor or null.
+ * @param {XrAnchor|null} anchor - The anchor that is tracked against real world geometry.
+ */
+
+/**
+ * Anchors provide an ability to specify a point in the world that needs to be updated to
+ * correctly reflect the evolving understanding of the world by the underlying AR system,
+ * such that the anchor remains aligned with the same place in the physical world.
+ * Anchors tend to persist better relative to the real world, especially during a longer
+ * session with lots of movement.
+ *
+ * ```javascript
+ * app.xr.start(camera, pc.XRTYPE_AR, pc.XRSPACE_LOCALFLOOR, {
+ *     anchors: true
+ * });
+ * ```
+ * @augments EventHandler
+ * @category XR
+ */
+class XrAnchors extends EventHandler {
+    /**
+     * @type {boolean}
+     * @private
+     */
+    _supported = platform.browser && !!window.XRAnchor;
+
+    /**
+     * List of anchor creation requests.
+     *
+     * @type {Array<object>}
+     * @private
+     */
+    _creationQueue = [];
+
+    /**
+     * Index of XrAnchors, with XRAnchor (native handle) used as a key.
+     *
+     * @type {Map<XRAnchor,XrAnchor>}
+     * @ignore
+     */
+    _index = new Map();
+
+    /**
+     * @type {Array<XrAnchor>}
+     * @ignore
+     */
+    _list = [];
+
+    /**
+     * Map of callbacks to XRAnchors so that we can call its callback once
+     * an anchor is updated with a pose for the first time.
+     *
+     * @type {Map<XrAnchor,XrAnchorCreate>}
+     * @private
+     */
+    _callbacksAnchors = new Map();
+
+    /**
+     * @param {import('./xr-manager.js').XrManager} manager - WebXR Manager.
+     * @hideconstructor
+     */
+    constructor(manager) {
+        super();
+
+        this.manager = manager;
+
+        if (this._supported) {
+            this.manager.on('end', this._onSessionEnd, this);
+        }
+    }
+
+    /**
+     * Fired when anchor failed to be created.
+     *
+     * @event XrAnchors#error
+     * @param {Error} error - Error object related to a failure of anchors.
+     */
+
+    /**
+     * Fired when a new {@link XrAnchor} is added.
+     *
+     * @event XrAnchors#add
+     * @param {XrAnchor} anchor - Anchor that has been added.
+     * @example
+     * app.xr.anchors.on('add', function (anchor) {
+     *     // new anchor is added
+     * });
+     */
+
+    /**
+     * Fired when an {@link XrAnchor} is destroyed.
+     *
+     * @event XrAnchors#destroy
+     * @param {XrAnchor} anchor - Anchor that has been destroyed.
+     * @example
+     * app.xr.anchors.on('destroy', function (anchor) {
+     *     // anchor that is destroyed
+     * });
+     */
+
+    /** @private */
+    _onSessionEnd() {
+        // clear anchor creation queue
+        for (let i = 0; i < this._creationQueue.length; i++) {
+            if (!this._creationQueue[i].callback)
+                continue;
+
+            this._creationQueue[i].callback(new Error('session ended'), null);
+        }
+        this._creationQueue.length = 0;
+
+        // destroy all anchors
+        if (this._list) {
+            let i = this._list.length;
+            while (i--) {
+                this._list[i].destroy();
+            }
+            this._list.length = 0;
+        }
+    }
+
+    /**
+     * Create anchor with position, rotation and a callback.
+     *
+     * @param {import('../../core/math/vec3.js').Vec3} position - Position for an anchor.
+     * @param {import('../../core/math/quat.js').Quat} [rotation] - Rotation for an anchor.
+     * @param {XrAnchorCreate} [callback] - Callback to fire when anchor was created or failed to be created.
+     * @example
+     * app.xr.anchors.create(position, rotation, function (err, anchor) {
+     *     if (!err) {
+     *         // new anchor has been created
+     *     }
+     * });
+     */
+    create(position, rotation, callback) {
+        this._creationQueue.push({
+            transform: new XRRigidTransform(position, rotation), // eslint-disable-line no-undef
+            callback: callback
+        });
+    }
+
+    /**
+     * @param {*} frame - XRFrame from requestAnimationFrame callback.
+     * @ignore
+     */
+    update(frame) {
+        // check if need to create anchors
+        if (this._creationQueue.length) {
+            for (let i = 0; i < this._creationQueue.length; i++) {
+                const request = this._creationQueue[i];
+
+                frame.createAnchor(request.transform, this.manager._referenceSpace)
+                    .then((xrAnchor) => {
+                        if (request.callback)
+                            this._callbacksAnchors.set(xrAnchor, request.callback);
+                    })
+                    .catch((ex) => {
+                        if (request.callback)
+                            request.callback(ex, null);
+
+                        this.fire('error', ex);
+                    });
+            }
+
+            this._creationQueue.length = 0;
+        }
+
+        // check if destroyed
+        for (const [xrAnchor, anchor] of this._index) {
+            if (frame.trackedAnchors.has(xrAnchor))
+                continue;
+
+            anchor.destroy();
+        }
+
+        // update existing anchors
+        for (let i = 0; i < this._list.length; i++) {
+            this._list[i].update(frame);
+        }
+
+        // check if added
+        for (const xrAnchor of frame.trackedAnchors) {
+            if (this._index.has(xrAnchor))
+                continue;
+
+            try {
+                const tmp = xrAnchor.anchorSpace; // eslint-disable-line no-unused-vars
+            } catch (ex) {
+                // if anchorSpace is not available, then anchor is invalid
+                // and should not be created
+                continue;
+            }
+
+            const anchor = new XrAnchor(this, xrAnchor);
+            this._index.set(xrAnchor, anchor);
+            this._list.push(anchor);
+            anchor.update(frame);
+
+            const callback = this._callbacksAnchors.get(xrAnchor);
+            if (callback) {
+                this._callbacksAnchors.delete(xrAnchor);
+                callback(null, anchor);
+            }
+
+            this.fire('add', anchor);
+        }
+    }
+
+    /**
+     * True if Anchors are supported.
+     *
+     * @type {boolean}
+     */
+    get supported() {
+        return this._supported;
+    }
+
+    /**
+     * List of available {@link XrAnchor}s.
+     *
+     * @type {Array<XrAnchor>}
+     */
+    get list() {
+        return this._list;
+    }
+}
+
+export { XrAnchors };
diff --git a/src/framework/xr/xr-manager.js b/src/framework/xr/xr-manager.js
index 0da070bd5..051ae143d 100644
--- a/src/framework/xr/xr-manager.js
+++ b/src/framework/xr/xr-manager.js
@@ -16,6 +16,7 @@ import { XrImageTracking } from './xr-image-tracking.js';
 import { XrInput } from './xr-input.js';
 import { XrLightEstimation } from './xr-light-estimation.js';
 import { XrPlaneDetection } from './xr-plane-detection.js';
+import { XrAnchors } from './xr-anchors.js';
 
 /**
  * Callback used by {@link XrManager#endXr} and {@link XrManager#startXr}.
@@ -75,7 +76,7 @@ class XrManager extends EventHandler {
 
     /**
      * @type {XRReferenceSpace|null}
-     * @private
+     * @ignore
      */
     _referenceSpace = null;
 
@@ -211,6 +212,7 @@ class XrManager extends EventHandler {
         this.planeDetection = new XrPlaneDetection(this);
         this.input = new XrInput(this);
         this.lightEstimation = new XrLightEstimation(this);
+        this.anchors = new XrAnchors(this);
 
         // TODO
         // 1. HMD class with its params
@@ -337,6 +339,8 @@ class XrManager extends EventHandler {
      * @param {object} [options] - Object with additional options for XR session initialization.
      * @param {string[]} [options.optionalFeatures] - Optional features for XRSession start. It is
      * used for getting access to additional WebXR spec extensions.
+     * @param {boolean} [options.anchors] - Set to true to attempt to enable
+     * {@link XrAnchors}.
      * @param {boolean} [options.imageTracking] - Set to true to attempt to enable
      * {@link XrImageTracking}.
      * @param {boolean} [options.planeDetection] - Set to true to attempt to enable
@@ -361,6 +365,8 @@ class XrManager extends EventHandler {
      * @example
      * button.on('click', function () {
      *     app.xr.start(camera, pc.XRTYPE_AR, pc.XRSPACE_LOCALFLOOR, {
+     *         anchors: true,
+     *         imageTracking: true,
      *         depthSensing: { }
      *     });
      * });
@@ -418,6 +424,10 @@ class XrManager extends EventHandler {
                 opts.domOverlay = { root: this.domOverlay.root };
             }
 
+            if (options && options.anchors && this.anchors.supported) {
+                opts.optionalFeatures.push('anchors');
+            }
+
             if (options && options.depthSensing && this.depthSensing.supported) {
                 opts.optionalFeatures.push('depth-sensing');
 
@@ -787,6 +797,9 @@ class XrManager extends EventHandler {
             if (this.imageTracking.supported)
                 this.imageTracking.update(frame);
 
+            if (this.anchors.supported)
+                this.anchors.update(frame);
+
             if (this.planeDetection.supported)
                 this.planeDetection.update(frame);
         }
diff --git a/src/index.js b/src/index.js
index 06b0ecdb4..e440b2e23 100644
--- a/src/index.js
+++ b/src/index.js
@@ -78,6 +78,9 @@ export { WebglGraphicsDevice } from './platform/graphics/webgl/webgl-graphics-de
 // PLATFORM / GRAPHICS / webgpu
 export { WebgpuGraphicsDevice } from './platform/graphics/webgpu/webgpu-graphics-device.js';
 
+// PLATFORM / GRAPHICS / null
+export { NullGraphicsDevice } from './platform/graphics/null/null-graphics-device.js';
+
 // PLATFORM / INPUT
 export * from './platform/input/constants.js';
 export { Controller } from './platform/input/controller.js';
@@ -328,6 +331,8 @@ export { XrHitTestSource } from './framework/xr/xr-hit-test-source.js';
 export { XrImageTracking } from './framework/xr/xr-image-tracking.js';
 export { XrTrackedImage } from './framework/xr/xr-tracked-image.js';
 export { XrDomOverlay } from './framework/xr/xr-dom-overlay.js';
+export { XrAnchors } from './framework/xr/xr-anchors.js';
+export { XrAnchor } from './framework/xr/xr-anchor.js';
 export { XrPlaneDetection } from './framework/xr/xr-plane-detection.js';
 export { XrPlane } from './framework/xr/xr-plane.js';
 
diff --git a/src/platform/audio/channel.js b/src/platform/audio/channel.js
index f8a96b78b..e61082e53 100644
--- a/src/platform/audio/channel.js
+++ b/src/platform/audio/channel.js
@@ -16,10 +16,10 @@ class Channel {
      * @param {import('../sound/manager.js').SoundManager} manager - The SoundManager instance.
      * @param {import('../sound/sound.js').Sound} sound - The sound to playback.
      * @param {object} [options] - Optional options object.
-     * @param {number} [options.volume=1] - The playback volume, between 0 and 1.
-     * @param {number} [options.pitch=1] - The relative pitch, default of 1, plays at normal pitch.
-     * @param {boolean} [options.loop=false] - Whether the sound should loop when it reaches the
-     * end or not.
+     * @param {number} [options.volume] - The playback volume, between 0 and 1. Defaults to 1.
+     * @param {number} [options.pitch] - The relative pitch. Defaults to 1 (plays at normal pitch).
+     * @param {boolean} [options.loop] - Whether the sound should loop when it reaches the
+     * end or not. Defaults to false.
      */
     constructor(manager, sound, options = {}) {
         this.volume = options.volume ?? 1;
diff --git a/src/platform/audio/channel3d.js b/src/platform/audio/channel3d.js
index 59ca061a7..87ba2090b 100644
--- a/src/platform/audio/channel3d.js
+++ b/src/platform/audio/channel3d.js
@@ -21,10 +21,10 @@ class Channel3d extends Channel {
      * @param {import('../sound/manager.js').SoundManager} manager - The SoundManager instance.
      * @param {import('../sound/sound.js').Sound} sound - The sound to playback.
      * @param {object} [options] - Optional options object.
-     * @param {number} [options.volume=1] - The playback volume, between 0 and 1.
-     * @param {number} [options.pitch=1] - The relative pitch, default of 1, plays at normal pitch.
-     * @param {boolean} [options.loop=false] - Whether the sound should loop when it reaches the
-     * end or not.
+     * @param {number} [options.volume] - The playback volume, between 0 and 1. Defaults to 1.
+     * @param {number} [options.pitch] - The relative pitch. Defaults to 1 (plays at normal pitch).
+     * @param {boolean} [options.loop] - Whether the sound should loop when it reaches the end or
+     * not. Defaults to false.
      */
     constructor(manager, sound, options) {
         super(manager, sound, options);
diff --git a/src/platform/graphics/constants.js b/src/platform/graphics/constants.js
index 91a1591c2..441b6c673 100644
--- a/src/platform/graphics/constants.js
+++ b/src/platform/graphics/constants.js
@@ -1236,6 +1236,13 @@ export const DEVICETYPE_WEBGL2 = 'webgl2';
  */
 export const DEVICETYPE_WEBGPU = 'webgpu';
 
+/**
+ * A Null device type.
+ *
+ * @type {string}
+ */
+export const DEVICETYPE_NULL = 'null';
+
 // (bit-flags) shader stages for resource visibility on the GPU
 export const SHADERSTAGE_VERTEX = 1;
 export const SHADERSTAGE_FRAGMENT = 2;
diff --git a/src/platform/graphics/graphics-device-create.js b/src/platform/graphics/graphics-device-create.js
index 152124918..16b52dcb5 100644
--- a/src/platform/graphics/graphics-device-create.js
+++ b/src/platform/graphics/graphics-device-create.js
@@ -1,9 +1,10 @@
 import { Debug } from '../../core/debug.js';
 import { platform } from '../../core/platform.js';
 
+import { DEVICETYPE_WEBGL2, DEVICETYPE_WEBGL1, DEVICETYPE_WEBGPU, DEVICETYPE_NULL } from './constants.js';
 import { WebgpuGraphicsDevice } from './webgpu/webgpu-graphics-device.js';
-import { DEVICETYPE_WEBGL2, DEVICETYPE_WEBGL1, DEVICETYPE_WEBGPU } from './constants.js';
 import { WebglGraphicsDevice } from './webgl/webgl-graphics-device.js';
+import { NullGraphicsDevice } from './null/null-graphics-device.js';
 
 /**
  * Creates a graphics device.
@@ -17,24 +18,25 @@ import { WebglGraphicsDevice } from './webgl/webgl-graphics-device.js';
  * {@link DEVICETYPE_WEBGPU}, or leave it empty.
  * @param {boolean} [options.antialias] - Boolean that indicates whether or not to perform
  * anti-aliasing if possible. Defaults to true.
- * @param {boolean} [options.depth=true] - Boolean that indicates that the drawing buffer is
- * requested to have a depth buffer of at least 16 bits.
- * @param {boolean} [options.stencil=true] - Boolean that indicates that the drawing buffer is
- * requested to have a stencil buffer of at least 8 bits.
+ * @param {boolean} [options.depth] - Boolean that indicates that the drawing buffer is
+ * requested to have a depth buffer of at least 16 bits. Defaults to true.
+ * @param {boolean} [options.stencil] - Boolean that indicates that the drawing buffer is
+ * requested to have a stencil buffer of at least 8 bits. Defaults to true.
  * @param {string} [options.glslangUrl] - The URL to the glslang script. Required if the
  * {@link DEVICETYPE_WEBGPU} type is added to deviceTypes array. Not used for
  * {@link DEVICETYPE_WEBGL1} or {@link DEVICETYPE_WEBGL2} device type creation.
  * @param {string} [options.twgslUrl] - An url to twgsl script, required if glslangUrl was specified.
  * @param {boolean} [options.xrCompatible] - Boolean that hints to the user agent to use a
  * compatible graphics adapter for an immersive XR device.
- * @param {'default'|'high-performance'|'low-power'} [options.powerPreference='default'] - A
- * hint indicating what configuration of GPU would be selected. Possible values are:
+ * @param {'default'|'high-performance'|'low-power'} [options.powerPreference] - A hint indicating
+ * what configuration of GPU would be selected. Possible values are:
  *
  * - 'default': Let the user agent decide which GPU configuration is most suitable. This is the
  * default value.
  * - 'high-performance': Prioritizes rendering performance over power consumption.
  * - 'low-power': Prioritizes power saving over rendering performance.
  *
+ * Defaults to 'default'.
  * @returns {Promise} - Promise object representing the created graphics device.
  */
 function createGraphicsDevice(canvas, options = {}) {
@@ -48,6 +50,9 @@ function createGraphicsDevice(canvas, options = {}) {
     if (!deviceTypes.includes(DEVICETYPE_WEBGL1)) {
         deviceTypes.push(DEVICETYPE_WEBGL1);
     }
+    if (!deviceTypes.includes(DEVICETYPE_NULL)) {
+        deviceTypes.push(DEVICETYPE_NULL);
+    }
 
     // XR compatibility if not specified
     if (platform.browser && !!navigator.xr) {
@@ -63,11 +68,16 @@ function createGraphicsDevice(canvas, options = {}) {
             return device.initWebGpu(options.glslangUrl, options.twgslUrl);
         }
 
-        if (deviceType !== DEVICETYPE_WEBGPU) {
+        if (deviceType === DEVICETYPE_WEBGL1 || deviceType === DEVICETYPE_WEBGL2) {
             options.preferWebGl2 = deviceType === DEVICETYPE_WEBGL2;
             device = new WebglGraphicsDevice(canvas, options);
             return Promise.resolve(device);
         }
+
+        if (deviceType === DEVICETYPE_NULL) {
+            device = new NullGraphicsDevice(canvas, options);
+            return Promise.resolve(device);
+        }
     }
 
     Debug.assert(device, 'Failed to allocate graphics device based on requested device types: ', options.deviceTypes);
diff --git a/src/platform/graphics/null/null-graphics-device.js b/src/platform/graphics/null/null-graphics-device.js
new file mode 100644
index 000000000..3ebf61124
--- /dev/null
+++ b/src/platform/graphics/null/null-graphics-device.js
@@ -0,0 +1,173 @@
+import { Debug } from '../../../core/debug.js';
+import {
+    PIXELFORMAT_RGBA8, DEVICETYPE_NULL
+} from '../constants.js';
+import { GraphicsDevice } from '../graphics-device.js';
+
+import { NullIndexBuffer } from './null-index-buffer.js';
+import { NullRenderTarget } from './null-render-target.js';
+import { NullShader } from './null-shader.js';
+import { NullTexture } from './null-texture.js';
+import { NullVertexBuffer } from './null-vertex-buffer.js';
+
+class NullGraphicsDevice extends GraphicsDevice {
+    constructor(canvas, options = {}) {
+        super(canvas, options);
+        options = this.initOptions;
+
+        this.isNull = true;
+        this._deviceType = DEVICETYPE_NULL;
+        this.samples = 1;
+
+        Debug.log('NullGraphicsDevice');
+    }
+
+    destroy() {
+        super.destroy();
+    }
+
+    initDeviceCaps() {
+
+        this.disableParticleSystem = true;
+        this.precision = 'highp';
+        this.maxPrecision = 'highp';
+        this.maxSamples = 4;
+        this.maxTextures = 16;
+        this.maxTextureSize = 4096;
+        this.maxCubeMapSize = 4096;
+        this.maxVolumeSize = 4096;
+        this.maxColorAttachments = 8;
+        this.maxPixelRatio = 1;
+        this.maxAnisotropy = 16;
+        this.supportsInstancing = true;
+        this.supportsUniformBuffers = false;
+        this.supportsVolumeTextures = true;
+        this.supportsBoneTextures = true;
+        this.supportsMorphTargetTexturesCore = true;
+        this.supportsAreaLights = true;
+        this.supportsDepthShadow = true;
+        this.supportsGpuParticles = false;
+        this.supportsMrt = true;
+        this.extUintElement = true;
+        this.extTextureFloat = true;
+        this.textureFloatRenderable = true;
+        this.extTextureHalfFloat = true;
+        this.textureHalfFloatRenderable = true;
+        this.textureHalfFloatUpdatable = true;
+        this.boneLimit = 1024;
+        this.supportsImageBitmap = true;
+        this.extStandardDerivatives = true;
+        this.extBlendMinmax = true;
+        this.areaLightLutFormat = PIXELFORMAT_RGBA8;
+        this.supportsTextureFetch = true;
+    }
+
+    postInit() {
+        super.postInit();
+    }
+
+    resizeCanvas(width, height) {
+        this._width = width;
+        this._height = height;
+    }
+
+    frameStart() {
+        super.frameStart();
+    }
+
+    frameEnd() {
+        super.frameEnd();
+    }
+
+    createVertexBufferImpl(vertexBuffer, format) {
+        return new NullVertexBuffer(vertexBuffer, format);
+    }
+
+    createIndexBufferImpl(indexBuffer) {
+        return new NullIndexBuffer(indexBuffer);
+    }
+
+    createShaderImpl(shader) {
+        return new NullShader(shader);
+    }
+
+    createTextureImpl(texture) {
+        return new NullTexture(texture);
+    }
+
+    createRenderTargetImpl(renderTarget) {
+        return new NullRenderTarget(renderTarget);
+    }
+
+    draw(primitive, numInstances = 1, keepBuffers) {
+    }
+
+    setShader(shader) {
+        return true;
+    }
+
+    setBlendState(blendState) {
+    }
+
+    setDepthState(depthState) {
+    }
+
+    setStencilState(stencilFront, stencilBack) {
+    }
+
+    setBlendColor(r, g, b, a) {
+    }
+
+    setCullMode(cullMode) {
+    }
+
+    setAlphaToCoverage(state) {
+    }
+
+    initializeContextCaches() {
+        super.initializeContextCaches();
+    }
+
+    startPass(renderPass) {
+    }
+
+    endPass(renderPass) {
+    }
+
+    clear(options) {
+    }
+
+    get width() {
+        return this._width;
+    }
+
+    get height() {
+        return this._height;
+    }
+
+    setDepthBias(on) {
+    }
+
+    setDepthBiasValues(constBias, slopeBias) {
+    }
+
+    setViewport(x, y, w, h) {
+    }
+
+    setScissor(x, y, w, h) {
+    }
+
+    copyRenderTarget(source, dest, color, depth) {
+        return true;
+    }
+
+    // #if _DEBUG
+    pushMarker(name) {
+    }
+
+    popMarker() {
+    }
+    // #endif
+}
+
+export { NullGraphicsDevice };
diff --git a/src/platform/graphics/null/null-index-buffer.js b/src/platform/graphics/null/null-index-buffer.js
new file mode 100644
index 000000000..45020e8d4
--- /dev/null
+++ b/src/platform/graphics/null/null-index-buffer.js
@@ -0,0 +1,11 @@
+/**
+ * A Null implementation of the IndexBuffer.
+ *
+ * @ignore
+ */
+class NullIndexBuffer {
+    unlock(indexBuffer) {
+    }
+}
+
+export { NullIndexBuffer };
diff --git a/src/platform/graphics/null/null-render-target.js b/src/platform/graphics/null/null-render-target.js
new file mode 100644
index 000000000..c91ef3754
--- /dev/null
+++ b/src/platform/graphics/null/null-render-target.js
@@ -0,0 +1,20 @@
+/**
+ * A Null implementation of the RenderTarget.
+ *
+ * @ignore
+ */
+class NullRenderTarget {
+    destroy(device) {
+    }
+
+    init(device, renderTarget) {
+    }
+
+    loseContext() {
+    }
+
+    resolve(device, target, color, depth) {
+    }
+}
+
+export { NullRenderTarget };
diff --git a/src/platform/graphics/null/null-shader.js b/src/platform/graphics/null/null-shader.js
new file mode 100644
index 000000000..87d51b885
--- /dev/null
+++ b/src/platform/graphics/null/null-shader.js
@@ -0,0 +1,17 @@
+/**
+ * A Null implementation of the Shader.
+ *
+ * @ignore
+ */
+class NullShader {
+    destroy(shader) {
+    }
+
+    loseContext() {
+    }
+
+    restoreContext(device, shader) {
+    }
+}
+
+export { NullShader };
diff --git a/src/platform/graphics/null/null-texture.js b/src/platform/graphics/null/null-texture.js
new file mode 100644
index 000000000..dad979f9d
--- /dev/null
+++ b/src/platform/graphics/null/null-texture.js
@@ -0,0 +1,17 @@
+/**
+ * A NULL implementation of the Texture.
+ *
+ * @ignore
+ */
+class NullTexture {
+    destroy(device) {
+    }
+
+    propertyChanged(flag) {
+    }
+
+    loseContext() {
+    }
+}
+
+export { NullTexture };
diff --git a/src/platform/graphics/null/null-vertex-buffer.js b/src/platform/graphics/null/null-vertex-buffer.js
new file mode 100644
index 000000000..3494f50ba
--- /dev/null
+++ b/src/platform/graphics/null/null-vertex-buffer.js
@@ -0,0 +1,14 @@
+/**
+ * A Null implementation of the VertexBuffer.
+ *
+ * @ignore
+ */
+class NullVertexBuffer {
+    destroy(device) {
+    }
+
+    unlock(vertexBuffer) {
+    }
+}
+
+export { NullVertexBuffer };
diff --git a/src/platform/graphics/webgl/webgl-graphics-device.js b/src/platform/graphics/webgl/webgl-graphics-device.js
index e62f667f7..343d8e991 100644
--- a/src/platform/graphics/webgl/webgl-graphics-device.js
+++ b/src/platform/graphics/webgl/webgl-graphics-device.js
@@ -275,34 +275,37 @@ class WebglGraphicsDevice extends GraphicsDevice {
      *
      * @param {HTMLCanvasElement} canvas - The canvas to which the graphics device will render.
      * @param {object} [options] - Options passed when creating the WebGL context.
-     * @param {boolean} [options.alpha=true] - Boolean that indicates if the canvas contains an
-     * alpha buffer.
-     * @param {boolean} [options.depth=true] - Boolean that indicates that the drawing buffer is
-     * requested to have a depth buffer of at least 16 bits.
-     * @param {boolean} [options.stencil=true] - Boolean that indicates that the drawing buffer is
-     * requested to have a stencil buffer of at least 8 bits.
-     * @param {boolean} [options.antialias=true] - Boolean that indicates whether or not to perform
-     * anti-aliasing if possible.
-     * @param {boolean} [options.premultipliedAlpha=true] - Boolean that indicates that the page
+     * @param {boolean} [options.alpha] - Boolean that indicates if the canvas contains an
+     * alpha buffer. Defaults to true.
+     * @param {boolean} [options.depth] - Boolean that indicates that the drawing buffer is
+     * requested to have a depth buffer of at least 16 bits. Defaults to true.
+     * @param {boolean} [options.stencil] - Boolean that indicates that the drawing buffer is
+     * requested to have a stencil buffer of at least 8 bits. Defaults to true.
+     * @param {boolean} [options.antialias] - Boolean that indicates whether or not to perform
+     * anti-aliasing if possible. Defaults to true.
+     * @param {boolean} [options.premultipliedAlpha] - Boolean that indicates that the page
      * compositor will assume the drawing buffer contains colors with pre-multiplied alpha.
-     * @param {boolean} [options.preserveDrawingBuffer=false] - If the value is true the buffers
-     * will not be cleared and will preserve their values until cleared or overwritten by the
-     * author.
-     * @param {'default'|'high-performance'|'low-power'} [options.powerPreference='default'] - A
-     * hint to the user agent indicating what configuration of GPU is suitable for the WebGL
-     * context. Possible values are:
+     * Defaults to true.
+     * @param {boolean} [options.preserveDrawingBuffer] - If the value is true the buffers will not
+     * be cleared and will preserve their values until cleared or overwritten by the author.
+     * Defaults to false.
+     * @param {'default'|'high-performance'|'low-power'} [options.powerPreference] - A hint to the
+     * user agent indicating what configuration of GPU is suitable for the WebGL context. Possible
+     * values are:
      *
      * - 'default': Let the user agent decide which GPU configuration is most suitable. This is the
      * default value.
      * - 'high-performance': Prioritizes rendering performance over power consumption.
      * - 'low-power': Prioritizes power saving over rendering performance.
      *
-     * @param {boolean} [options.failIfMajorPerformanceCaveat=false] - Boolean that indicates if a
+     * Defaults to 'default'.
+     * @param {boolean} [options.failIfMajorPerformanceCaveat] - Boolean that indicates if a
      * context will be created if the system performance is low or if no hardware GPU is available.
-     * @param {boolean} [options.preferWebGl2=true] - Boolean that indicates if a WebGl2 context
-     * should be preferred.
-     * @param {boolean} [options.desynchronized=false] - Boolean that hints the user agent to
-     * reduce the latency by desynchronizing the canvas paint cycle from the event loop.
+     * Defaults to false.
+     * @param {boolean} [options.preferWebGl2] - Boolean that indicates if a WebGl2 context should
+     * be preferred. Defaults to true.
+     * @param {boolean} [options.desynchronized] - Boolean that hints the user agent to reduce the
+     * latency by desynchronizing the canvas paint cycle from the event loop. Defaults to false.
      * @param {boolean} [options.xrCompatible] - Boolean that hints to the user agent to use a
      * compatible graphics adapter for an immersive XR device.
      * @param {WebGLRenderingContext | WebGL2RenderingContext} [options.gl] - The rendering context
@@ -1863,7 +1866,7 @@ class WebglGraphicsDevice extends GraphicsDevice {
      * call.
      * @param {boolean} [primitive.indexed] - True to interpret the primitive as indexed, thereby
      * using the currently set index buffer and false otherwise.
-     * @param {number} [numInstances=1] - The number of instances to render when using
+     * @param {number} [numInstances] - The number of instances to render when using
      * ANGLE_instanced_arrays. Defaults to 1.
      * @param {boolean} [keepBuffers] - Optionally keep the current set of vertex / index buffers /
      * VAO. This is used when rendering of multiple views, for example under WebXR.
@@ -2023,10 +2026,10 @@ class WebglGraphicsDevice extends GraphicsDevice {
      *
      * @param {object} [options] - Optional options object that controls the behavior of the clear
      * operation defined as follows:
-     * @param {number[]} [options.color] - The color to clear the color buffer to in the range 0.0
-     * to 1.0 for each component.
-     * @param {number} [options.depth=1] - The depth value to clear the depth buffer to in the
-     * range 0.0 to 1.0.
+     * @param {number[]} [options.color] - The color to clear the color buffer to in the range 0 to
+     * 1 for each component.
+     * @param {number} [options.depth] - The depth value to clear the depth buffer to in the
+     * range 0 to 1. Defaults to 1.
      * @param {number} [options.flags] - The buffers to clear (the types being color, depth and
      * stencil). Can be any bitwise combination of:
      *
@@ -2034,9 +2037,10 @@ class WebglGraphicsDevice extends GraphicsDevice {
      * - {@link CLEARFLAG_DEPTH}
      * - {@link CLEARFLAG_STENCIL}
      *
-     * @param {number} [options.stencil=0] - The stencil value to clear the stencil buffer to. Defaults to 0.
+     * @param {number} [options.stencil] - The stencil value to clear the stencil buffer to.
+     * Defaults to 0.
      * @example
-     * // Clear color buffer to black and depth buffer to 1.0
+     * // Clear color buffer to black and depth buffer to 1
      * device.clear();
      *
      * // Clear just the color buffer to red
@@ -2141,7 +2145,8 @@ class WebglGraphicsDevice extends GraphicsDevice {
 
         if (!this.webgl2) {
             // async fences aren't supported on webgl1
-            return this.readPixels(x, y, w, h, pixels);
+            this.readPixels(x, y, w, h, pixels);
+            return;
         }
 
         const clientWaitAsync = (flags, interval_ms) => {
diff --git a/src/platform/sound/instance.js b/src/platform/sound/instance.js
index 570f55951..4e232eba4 100644
--- a/src/platform/sound/instance.js
+++ b/src/platform/sound/instance.js
@@ -42,19 +42,19 @@ class SoundInstance extends EventHandler {
      * @param {import('./manager.js').SoundManager} manager - The sound manager.
      * @param {import('./sound.js').Sound} sound - The sound to play.
      * @param {object} options - Options for the instance.
-     * @param {number} [options.volume=1] - The playback volume, between 0 and 1.
-     * @param {number} [options.pitch=1] - The relative pitch, default of 1, plays at normal pitch.
-     * @param {boolean} [options.loop=false] - Whether the sound should loop when it reaches the
-     * end or not.
-     * @param {number} [options.startTime=0] - The time from which the playback will start in
-     * seconds. Default is 0 to start at the beginning.
-     * @param {number} [options.duration=0] - The total time after the startTime in seconds when
-     * playback will stop or restart if loop is true.
-     * @param {Function} [options.onPlay=null] - Function called when the instance starts playing.
-     * @param {Function} [options.onPause=null] - Function called when the instance is paused.
-     * @param {Function} [options.onResume=null] - Function called when the instance is resumed.
-     * @param {Function} [options.onStop=null] - Function called when the instance is stopped.
-     * @param {Function} [options.onEnd=null] - Function called when the instance ends.
+     * @param {number} [options.volume] - The playback volume, between 0 and 1. Defaults to 1.
+     * @param {number} [options.pitch] - The relative pitch. Defaults to 1 (plays at normal pitch).
+     * @param {boolean} [options.loop] - Whether the sound should loop when it reaches the end or
+     * not. Defaults to false.
+     * @param {number} [options.startTime] - The time from which the playback will start in
+     * seconds. Default is 0 to start at the beginning. Defaults to 0.
+     * @param {number} [options.duration] - The total time after the startTime in seconds when
+     * playback will stop or restart if loop is true. Defaults to 0.
+     * @param {Function} [options.onPlay] - Function called when the instance starts playing.
+     * @param {Function} [options.onPause] - Function called when the instance is paused.
+     * @param {Function} [options.onResume] - Function called when the instance is resumed.
+     * @param {Function} [options.onStop] - Function called when the instance is stopped.
+     * @param {Function} [options.onEnd] - Function called when the instance ends.
      */
     constructor(manager, sound, options) {
         super();
diff --git a/src/platform/sound/instance3d.js b/src/platform/sound/instance3d.js
index 8d1a0e96c..a8829f082 100644
--- a/src/platform/sound/instance3d.js
+++ b/src/platform/sound/instance3d.js
@@ -35,29 +35,30 @@ class SoundInstance3d extends SoundInstance {
      * @param {import('./manager.js').SoundManager} manager - The sound manager.
      * @param {import('./sound.js').Sound} sound - The sound to play.
      * @param {object} options - Options for the instance.
-     * @param {number} [options.volume=1] - The playback volume, between 0 and 1.
-     * @param {number} [options.pitch=1] - The relative pitch, default of 1, plays at normal pitch.
-     * @param {boolean} [options.loop=false] - Whether the sound should loop when it reaches the
-     * end or not.
-     * @param {number} [options.startTime=0] - The time from which the playback will start. Default
+     * @param {number} [options.volume] - The playback volume, between 0 and 1. Defaults to 1.
+     * @param {number} [options.pitch] - The relative pitch. Defaults to 1 (plays at normal pitch).
+     * @param {boolean} [options.loop] - Whether the sound should loop when it reaches the end or
+     * not. Defaults to false.
+     * @param {number} [options.startTime] - The time from which the playback will start. Default
      * is 0 to start at the beginning.
-     * @param {number} [options.duration=null] - The total time after the startTime when playback
-     * will stop or restart if loop is true.
-     * @param {Vec3} [options.position=null] - The position of the sound in 3D space.
-     * @param {string} [options.distanceModel=DISTANCE_LINEAR] - Determines which algorithm to use
-     * to reduce the volume of the audio as it moves away from the listener. Can be:
+     * @param {number} [options.duration] - The total time after the startTime when playback will
+     * stop or restart if loop is true.
+     * @param {Vec3} [options.position] - The position of the sound in 3D space.
+     * @param {string} [options.distanceModel] - Determines which algorithm to use to reduce the
+     * volume of the audio as it moves away from the listener. Can be:
      *
      * - {@link DISTANCE_LINEAR}
      * - {@link DISTANCE_INVERSE}
      * - {@link DISTANCE_EXPONENTIAL}
      *
-     * Default is {@link DISTANCE_LINEAR}.
-     * @param {number} [options.refDistance=1] - The reference distance for reducing volume as the
-     * sound source moves further from the listener.
-     * @param {number} [options.maxDistance=10000] - The maximum distance from the listener at which
+     * Defaults to {@link DISTANCE_LINEAR}.
+     * @param {number} [options.refDistance] - The reference distance for reducing volume as the
+     * sound source moves further from the listener. Defaults to 1.
+     * @param {number} [options.maxDistance] - The maximum distance from the listener at which
      * audio falloff stops. Note the volume of the audio is not 0 after this distance, but just
-     * doesn't fall off anymore.
-     * @param {number} [options.rollOffFactor=1] - The factor used in the falloff equation.
+     * doesn't fall off anymore. Defaults to 10000.
+     * @param {number} [options.rollOffFactor] - The factor used in the falloff equation. Defaults
+     * to 1.
      */
     constructor(manager, sound, options = {}) {
         super(manager, sound, options);
diff --git a/src/scene/batching/batch-manager.js b/src/scene/batching/batch-manager.js
index eac726112..cb040f7c8 100644
--- a/src/scene/batching/batch-manager.js
+++ b/src/scene/batching/batch-manager.js
@@ -712,8 +712,7 @@ class BatchManager {
                             } else if (semantic === SEMANTIC_NORMAL || semantic === SEMANTIC_TANGENT) {
 
                                 // handle non-uniform scale by using transposed inverse matrix to transform vectors
-                                transform.invertTo3x3(mat3);
-                                mat3.transpose();
+                                mat3.invertMat4(transform).transpose();
 
                                 for (let j = 0; j < totalComponents; j += stream.numComponents) {
                                     vec.set(subarray[j], subarray[j + 1], subarray[j + 2]);
diff --git a/src/scene/composition/layer-composition.js b/src/scene/composition/layer-composition.js
index 1e13bd33e..705645c9a 100644
--- a/src/scene/composition/layer-composition.js
+++ b/src/scene/composition/layer-composition.js
@@ -2,7 +2,6 @@ import { TRACEID_RENDER_ACTION } from '../../core/constants.js';
 import { Debug } from '../../core/debug.js';
 import { Tracing } from '../../core/tracing.js';
 import { EventHandler } from '../../core/event-handler.js';
-import { set } from '../../core/set-utils.js';
 import { sortPriority } from '../../core/sort.js';
 
 import {
@@ -12,11 +11,8 @@ import {
 } from '../constants.js';
 
 import { RenderAction } from './render-action.js';
-import { WorldClusters } from '../lighting/world-clusters.js';
-import { LightCompositionData } from './light-composition-data.js';
 
 const tempSet = new Set();
-const tempClusterArray = [];
 
 /**
  * Layer Composition is a collection of {@link Layer} that is fed to {@link Scene#layers} to define
@@ -108,53 +104,16 @@ class LayerComposition extends EventHandler {
         // a map of Light to index in _lights for fast lookup
         this._lightsMap = new Map();
 
-        // each entry in _lights has entry of type LightCompositionData here at the same index,
-        // storing shadow casters and additional composition related data for the light
-        this._lightCompositionData = [];
-
         // _lights split into arrays per type of light, indexed by LIGHTTYPE_*** constants
         this._splitLights = [[], [], []];
-
-        // all currently created light clusters, that need to be updated before rendering
-        this._worldClusters = [];
-
-        // empty cluster with no lights
-        this._emptyWorldClusters = null;
     }
 
     destroy() {
-        // empty light cluster
-        if (this._emptyWorldClusters) {
-            this._emptyWorldClusters.destroy();
-            this._emptyWorldClusters = null;
-        }
-
-        // all other clusters
-        this._worldClusters.forEach((cluster) => {
-            cluster.destroy();
-        });
-        this._worldClusters = null;
-
         // render actions
         this._renderActions.forEach(ra => ra.destroy());
         this._renderActions = null;
     }
 
-    // returns an empty light cluster object to be used when no lights are used
-    getEmptyWorldClusters(device) {
-        if (!this._emptyWorldClusters) {
-
-            // create cluster structure with no lights
-            this._emptyWorldClusters = new WorldClusters(device);
-            this._emptyWorldClusters.name = 'ClusterEmpty';
-
-            // update it once to avoid doing it each frame
-            this._emptyWorldClusters.update([], false, null);
-        }
-
-        return this._emptyWorldClusters;
-    }
-
     // function which splits list of lights on a a target object into separate lists of lights based on light type
     _splitLightsArray(target) {
 
@@ -178,7 +137,7 @@ class LayerComposition extends EventHandler {
         splitLights[LIGHTTYPE_SPOT].sort((a, b) => a.key - b.key);
     }
 
-    _update(device, clusteredLightingEnabled = false) {
+    _update() {
         const len = this.layerList.length;
         let result = 0;
 
@@ -202,11 +161,6 @@ class LayerComposition extends EventHandler {
             this.updateLights();
         }
 
-        // if meshes OR lights changed, rebuild shadow casters
-        if (result) {
-            this.updateShadowCasters();
-        }
-
         if (this._dirtyCameras || (result & COMPUPDATED_LIGHTS)) {
 
             this._dirtyCameras = false;
@@ -324,15 +278,6 @@ class LayerComposition extends EventHandler {
             this._renderActions.length = renderActionCount;
         }
 
-        // allocate light clusteres if lights or meshes or cameras are modified
-        if (result & (COMPUPDATED_CAMERAS | COMPUPDATED_LIGHTS)) {
-
-            // prepare clustered lighting for render actions
-            if (clusteredLightingEnabled) {
-                this.allocateLightClusters(device);
-            }
-        }
-
         if (result & (COMPUPDATED_LIGHTS | COMPUPDATED_LIGHTS)) {
             this._logRenderActions();
         }
@@ -340,44 +285,6 @@ class LayerComposition extends EventHandler {
         return result;
     }
 
-    updateShadowCasters() {
-
-        // _lightCompositionData already has the right size, just clean up shadow casters
-        const lightCount = this._lights.length;
-        for (let i = 0; i < lightCount; i++) {
-            this._lightCompositionData[i].clearShadowCasters();
-        }
-
-        // for each layer
-        const len = this.layerList.length;
-        for (let i = 0; i < len; i++) {
-            const layer = this.layerList[i];
-
-            // layer can be in the list two times (opaque, transp), add casters only one time
-            if (!tempSet.has(layer)) {
-                tempSet.add(layer);
-
-                // for each light of a layer
-                const lights = layer._lights;
-                for (let j = 0; j < lights.length; j++) {
-
-                    // only need casters when casting shadows
-                    if (lights[j].castShadows) {
-
-                        // find its index in global light list, and get shadow casters for it
-                        const lightIndex = this._lightsMap.get(lights[j]);
-                        const lightCompData = this._lightCompositionData[lightIndex];
-
-                        // add unique meshes from the layer to casters
-                        lightCompData.addShadowCasters(layer.shadowCasters);
-                    }
-                }
-            }
-        }
-
-        tempSet.clear();
-    }
-
     updateLights() {
 
         // build a list and map of all unique lights from all layers
@@ -402,13 +309,6 @@ class LayerComposition extends EventHandler {
                         lightIndex = this._lights.length;
                         this._lightsMap.set(light, lightIndex);
                         this._lights.push(light);
-
-                        // make sure the light has composition data allocated
-                        let lightCompData = this._lightCompositionData[lightIndex];
-                        if (!lightCompData) {
-                            lightCompData = new LightCompositionData();
-                            this._lightCompositionData[lightIndex] = lightCompData;
-                        }
                     }
                 }
             }
@@ -422,100 +322,6 @@ class LayerComposition extends EventHandler {
 
         // split light list by type
         this._splitLightsArray(this);
-
-        // adjust _lightCompositionData to the right size, matching number of lights
-        const lightCount = this._lights.length;
-        this._lightCompositionData.length = lightCount;
-    }
-
-    // find existing light cluster that is compatible with specified layer
-    findCompatibleCluster(layer, renderActionCount, emptyWorldClusters) {
-
-        // check already set up render actions
-        for (let i = 0; i < renderActionCount; i++) {
-            const ra = this._renderActions[i];
-            const raLayer = this.layerList[ra.layerIndex];
-
-            // only reuse clusters if not empty
-            if (ra.lightClusters !== emptyWorldClusters) {
-
-                // if layer is the same (but different sublayer), cluster can be used directly as lights are the same
-                if (layer === raLayer) {
-                    return ra.lightClusters;
-                }
-
-                if (ra.lightClusters) {
-                    // if the layer has exactly the same set of lights, use the same cluster
-                    if (set.equals(layer._clusteredLightsSet, raLayer._clusteredLightsSet)) {
-                        return ra.lightClusters;
-                    }
-                }
-            }
-        }
-
-        // no match
-        return null;
-    }
-
-    // assign light clusters to render actions that need it
-    allocateLightClusters(device) {
-
-        // reuse previously allocated clusters
-        tempClusterArray.push(...this._worldClusters);
-
-        // the cluster with no lights
-        const emptyWorldClusters = this.getEmptyWorldClusters(device);
-
-        // start with no clusters
-        this._worldClusters.length = 0;
-
-        // process all render actions
-        const count = this._renderActions.length;
-        for (let i = 0; i < count; i++) {
-            const ra = this._renderActions[i];
-            const layer = this.layerList[ra.layerIndex];
-
-            ra.lightClusters = null;
-
-            // if the layer has lights used by clusters
-            if (layer.hasClusteredLights) {
-
-                // and if the layer has meshes
-                if (layer.meshInstances.length) {
-
-                    // reuse cluster that was already set up and is compatible
-                    let clusters = this.findCompatibleCluster(layer, i, emptyWorldClusters);
-                    if (!clusters) {
-
-                        // use already allocated cluster from before
-                        if (tempClusterArray.length) {
-                            clusters = tempClusterArray.pop();
-                        }
-
-                        // create new cluster
-                        if (!clusters) {
-                            clusters = new WorldClusters(device);
-                        }
-
-                        clusters.name = 'Cluster-' + this._worldClusters.length;
-                        this._worldClusters.push(clusters);
-                    }
-
-                    ra.lightClusters = clusters;
-                }
-            }
-
-            // no clustered lights, use the cluster with no lights
-            if (!ra.lightClusters) {
-                ra.lightClusters = emptyWorldClusters;
-            }
-        }
-
-        // delete leftovers
-        tempClusterArray.forEach((item) => {
-            item.destroy();
-        });
-        tempClusterArray.length = 0;
     }
 
     // function adds new render action to a list, while trying to limit allocation and reuse already allocated objects
@@ -569,6 +375,7 @@ class LayerComposition extends EventHandler {
         renderAction.reset();
         renderAction.triggerPostprocess = false;
         renderAction.layerIndex = layerIndex;
+        renderAction.layer = layer;
         renderAction.cameraIndex = cameraIndex;
         renderAction.camera = camera;
         renderAction.renderTarget = rt;
@@ -635,11 +442,8 @@ class LayerComposition extends EventHandler {
                     (' Lay: ' + layer.name).padEnd(22, ' ') +
                     (transparent ? ' TRANSP' : ' OPAQUE') +
                     (enabled ? ' ENABLED ' : ' DISABLED') +
-                    ' Meshes: ', ('?' + layer.meshInstances.length).padStart(5) +
                     (' RT: ' + (ra.renderTarget ? ra.renderTarget.name : '-')).padEnd(30, ' ') +
                     ' Clear: ' + clear +
-                    ' Lights: (' + layer._clusteredLightsSet.size + '/' + layer._lightsSet.size + ')' +
-                    ' ' + (ra.lightClusters !== this._emptyWorldClusters ? (ra.lightClusters.name) : '').padEnd(10, ' ') +
                     (ra.firstCameraUse ? ' CAM-FIRST' : '') +
                     (ra.lastCameraUse ? ' CAM-LAST' : '') +
                     (ra.triggerPostprocess ? ' POSTPROCESS' : '') +
diff --git a/src/scene/composition/light-composition-data.js b/src/scene/composition/light-composition-data.js
deleted file mode 100644
index 2b22c7410..000000000
--- a/src/scene/composition/light-composition-data.js
+++ /dev/null
@@ -1,30 +0,0 @@
-// Storage of data the LayerComposition needs to manage a light
-class LightCompositionData {
-    constructor() {
-
-        // stored in a set for fast de-duplication
-        this.shadowCastersSet = new Set();
-
-        // stored in an array for fast iteration
-        this.shadowCastersList = [];
-    }
-
-    clearShadowCasters() {
-        this.shadowCastersSet.clear();
-        this.shadowCastersList.length = 0;
-    }
-
-    addShadowCasters(casters) {
-
-        // add unique casters to the set and the list
-        for (let i = 0; i < casters.length; i++) {
-            const item = casters[i];
-            if (!this.shadowCastersSet.has(item)) {
-                this.shadowCastersSet.add(item);
-                this.shadowCastersList.push(item);
-            }
-        }
-    }
-}
-
-export { LightCompositionData };
diff --git a/src/scene/composition/render-action.js b/src/scene/composition/render-action.js
index cbb4995ed..691fe8678 100644
--- a/src/scene/composition/render-action.js
+++ b/src/scene/composition/render-action.js
@@ -1,7 +1,3 @@
-import {
-    LIGHTTYPE_DIRECTIONAL
-} from '../constants.js';
-
 /**
  * Class representing an entry in the final order of rendering of cameras and layers in the engine
  * this is populated at runtime based on LayerComposition
@@ -14,6 +10,9 @@ class RenderAction {
         // index into a layer stored in LayerComposition.layerList
         this.layerIndex = 0;
 
+        // the layer
+        this.layer = null;
+
         // index into a camera array of the layer, stored in Layer.cameras
         this.cameraIndex = 0;
 
@@ -50,9 +49,6 @@ class RenderAction {
         // and also store them as an array
         this.directionalLights = [];
 
-        // and also the same directional lights, stored as indices into LayerComposition._lights
-        this.directionalLightsIndices = [];
-
         // an array of view bind groups (the number of these corresponds to the number of views when XR is used)
         /** @type {import('../../platform/graphics/bind-group.js').BindGroup[]} */
         this.viewBindGroups = [];
@@ -76,7 +72,6 @@ class RenderAction {
         this.lightClusters = null;
         this.directionalLightsSet.clear();
         this.directionalLights.length = 0;
-        this.directionalLightsIndices.length = 0;
     }
 
     /**
@@ -94,7 +89,6 @@ class RenderAction {
 
         this.directionalLightsSet.clear();
         this.directionalLights.length = 0;
-        this.directionalLightsIndices.length = 0;
 
         for (let i = 0; i < dirLights.length; i++) {
             const light = dirLights[i];
@@ -104,15 +98,10 @@ class RenderAction {
                 for (let l = 0; l < cameraLayers.length; l++) {
 
                     // if layer has the light
-                    if (cameraLayers[l]._splitLights[LIGHTTYPE_DIRECTIONAL].indexOf(light) >= 0) {
+                    if (cameraLayers[l]._lightsSet.has(light)) {
                         if (!this.directionalLightsSet.has(light)) {
                             this.directionalLightsSet.add(light);
-
                             this.directionalLights.push(light);
-
-                            // store index into all lights
-                            const lightIndex = allLights.indexOf(light);
-                            this.directionalLightsIndices.push(lightIndex);
                         }
                     }
                 }
diff --git a/src/scene/compress/compress-utils.js b/src/scene/compress/compress-utils.js
index 8d57aed90..1d4977a9d 100644
--- a/src/scene/compress/compress-utils.js
+++ b/src/scene/compress/compress-utils.js
@@ -1,15 +1,12 @@
-const CompressUtils = {
+class CompressUtils {
     /**
-     * @private
-     * @function
-     * @name CompressUtils#setCompressedPRS
-     * @description Set position, rotation and scale of an entity using compressed
-     * scene format.
+     * Set position, rotation and scale of an entity using compressed scene format.
+     *
      * @param {import('../../framework/entity.js').Entity} entity - The entity.
      * @param {object} data - Json entity data from a compressed scene.
      * @param {object} compressed - Compression metadata.
      */
-    setCompressedPRS: function (entity, data, compressed) {
+    static setCompressedPRS(entity, data, compressed) {
         const a = compressed.singleVecs;
 
         let b, i;
@@ -33,35 +30,29 @@ const CompressUtils = {
         n = v ? v[2] : b[i + 2];
 
         entity.setLocalScale(a[n], a[n + 1], a[n + 2]);
-    },
+    }
 
     /**
-     * @private
-     * @function
-     * @name CompressUtils#oneCharToKey
-     * @description Retrieve the original field name (key) for a single character key
-     * from a compressed entity.
+     * Retrieve the original field name (key) for a single character key from a compressed entity.
+     *
      * @param {string} s - The compressed key string.
      * @param {object} data - Compression metadata.
      * @returns {string} The original key.
      */
-    oneCharToKey: function (s, data) {
+    static oneCharToKey(s, data) {
         const i = s.charCodeAt(0) - data.fieldFirstCode;
 
         return data.fieldArray[i];
-    },
+    }
 
     /**
-     * @private
-     * @function
-     * @name CompressUtils#multCharToKey
-     * @description Retrieve the original field name (key) for a multi-character key
-     * from a compressed entity.
+     * Retrieve the original field name (key) for a multi-character key from a compressed entity.
+     *
      * @param {string} s - The compressed key string.
      * @param {object} data - Compression metadata.
      * @returns {string} The original key.
      */
-    multCharToKey: function (s, data) {
+    static multCharToKey(s, data) {
         let ind = 0;
 
         for (let i = 0; i < s.length; i++) {
@@ -70,6 +61,6 @@ const CompressUtils = {
 
         return data.fieldArray[ind];
     }
-};
+}
 
 export { CompressUtils };
diff --git a/src/scene/constants.js b/src/scene/constants.js
index 2f63d101e..936b480c5 100644
--- a/src/scene/constants.js
+++ b/src/scene/constants.js
@@ -471,7 +471,7 @@ export const DETAILMODE_ADD = 'add';
 /**
  * Softer version of {@link DETAILMODE_ADD}.
  *
- * @name DETAILMODE_SCREEN
+ * @type {string}
  */
 export const DETAILMODE_SCREEN = 'screen';
 
diff --git a/src/scene/graph-node.js b/src/scene/graph-node.js
index 5eb85f7e2..7991721ca 100644
--- a/src/scene/graph-node.js
+++ b/src/scene/graph-node.js
@@ -295,8 +295,7 @@ class GraphNode extends EventHandler {
 
         const normalMat = this._normalMatrix;
         if (this._dirtyNormal) {
-            this.getWorldTransform().invertTo3x3(normalMat);
-            normalMat.transpose();
+            normalMat.invertMat4(this.getWorldTransform()).transpose();
             this._dirtyNormal = false;
         }
 
@@ -1496,9 +1495,9 @@ class GraphNode extends EventHandler {
      * @param {Vec3|number} [y] - If passing a 3D vector, this is the world-space up vector for look at
      * transform. Otherwise, it is the y-component of the world-space coordinate to look at.
      * @param {number} [z] - Z-component of the world-space coordinate to look at.
-     * @param {number} [ux=0] - X-component of the up vector for the look at transform.
-     * @param {number} [uy=1] - Y-component of the up vector for the look at transform.
-     * @param {number} [uz=0] - Z-component of the up vector for the look at transform.
+     * @param {number} [ux] - X-component of the up vector for the look at transform. Defaults to 0.
+     * @param {number} [uy] - Y-component of the up vector for the look at transform. Defaults to 1.
+     * @param {number} [uz] - Z-component of the up vector for the look at transform. Defaults to 0.
      * @example
      * // Look at another entity, using the (default) positive y-axis for up
      * const position = otherEntity.getPosition();
diff --git a/src/scene/graphics/light-cube.js b/src/scene/graphics/light-cube.js
new file mode 100644
index 000000000..c2596fe15
--- /dev/null
+++ b/src/scene/graphics/light-cube.js
@@ -0,0 +1,49 @@
+import { Vec3 } from "../../core/math/vec3.js";
+import { LIGHTTYPE_DIRECTIONAL } from "../constants.js";
+
+const lightCubeDir = [
+    new Vec3(-1, 0, 0),
+    new Vec3(1, 0, 0),
+    new Vec3(0, -1, 0),
+    new Vec3(0, 1, 0),
+    new Vec3(0, 0, -1),
+    new Vec3(0, 0, 1)
+];
+
+/**
+ * A lighting cube represented by 6 colors, one per cube direction. Use for simple lighting on the
+ * particle system.
+ *
+ * @ignore
+ */
+class LightCube {
+    colors = new Float32Array(6 * 3);
+
+    update(ambientLight, lights) {
+        const colors = this.colors;
+
+        // ambient contribution
+        const { r, g, b } = ambientLight;
+        for (let j = 0; j < 6; j++) {
+            colors[j * 3] = r;
+            colors[j * 3 + 1] = g;
+            colors[j * 3 + 2] = b;
+        }
+
+        // directional contribution
+        for (let j = 0; j < lights.length; j++) {
+            const light = lights[j];
+            if (light._type === LIGHTTYPE_DIRECTIONAL) {
+                for (let c = 0; c < 6; c++) {
+                    const weight = Math.max(lightCubeDir[c].dot(light._direction), 0) * light._intensity;
+                    const lightColor = light._color;
+                    colors[c * 3] += lightColor.r * weight;
+                    colors[c * 3 + 1] += lightColor.g * weight;
+                    colors[c * 3 + 2] += lightColor.b * weight;
+                }
+            }
+        }
+    }
+}
+
+export { LightCube };
diff --git a/src/scene/graphics/scene-grab.js b/src/scene/graphics/scene-grab.js
index 7f0bfda21..be8bc22f6 100644
--- a/src/scene/graphics/scene-grab.js
+++ b/src/scene/graphics/scene-grab.js
@@ -54,6 +54,18 @@ class SceneGrab {
         // create depth layer
         this.layer = null;
 
+        // null device does not support scene grab
+        if (this.device.isNull) {
+
+            this.layer = new Layer({
+                enabled: false,
+                name: "Depth",
+                id: LAYERID_DEPTH
+            });
+
+            return;
+        }
+
         // create a depth layer, which is a default depth layer, but also a template used
         // to patch application created depth layers to behave as one
         if (this.device.webgl2 || this.device.isWebGPU) {
diff --git a/src/scene/layer.js b/src/scene/layer.js
index 2ee42f534..62ea57891 100644
--- a/src/scene/layer.js
+++ b/src/scene/layer.js
@@ -37,11 +37,14 @@ function sortLights(lightA, lightB) {
     return lightB.key - lightA.key;
 }
 
+function sortLightsIds(lightA, lightB) {
+    return lightB.id - lightA.id;
+}
+
 // Layers
 let layerCounter = 0;
 
 const lightKeys = [];
-
 const _tempMaterials = new Set();
 
 class CulledInstances {
@@ -76,6 +79,14 @@ class Layer {
      */
     meshInstances = [];
 
+    /**
+     * Mesh instances assigned to this layer, stored in a set.
+     *
+     * @type {Set<import('./mesh-instance.js').MeshInstance>}
+     * @ignore
+     */
+    meshInstancesSet = new Set();
+
     /**
      * Shadow casting instances assigned to this layer.
      *
@@ -84,6 +95,14 @@ class Layer {
      */
     shadowCasters = [];
 
+    /**
+     * Shadow casting instances assigned to this layer, stored in a set.
+     *
+     * @type {Set<import('./mesh-instance.js').MeshInstance>}
+     * @ignore
+     */
+    shadowCastersSet = new Set();
+
     /**
      * Visible (culled) mesh instances assigned to this layer. Looked up by the Camera.
      *
@@ -92,6 +111,36 @@ class Layer {
      */
     _visibleInstances = new WeakMap();
 
+    /**
+     * All lights assigned to a layer.
+     *
+     * @type {import('./light.js').Light[]}
+     * @private
+     */
+    _lights = [];
+
+    /**
+     * All lights assigned to a layer stored in a set.
+     *
+     * @type {Set<import('./light.js').Light>}
+     * @private
+     */
+
+    _lightsSet = new Set();
+
+    /**
+     * Set of light used by clustered lighting (omni and spot, but no directional).
+     *
+     * @type {Set<import('./light.js').Light>}
+     * @private
+     */
+    _clusteredLightsSet = new Set();
+
+    /**
+     * True if the objects rendered on the layer require light cube (emitters with lighting do).
+     */
+    requiresLightCube = false;
+
     /**
      * Create a new Layer instance.
      *
@@ -333,25 +382,6 @@ class Layer {
          */
         this.customCalculateSortValues = null;
 
-        /**
-         * @type {import('./light.js').Light[]}
-         * @private
-         */
-        this._lights = [];
-        /**
-         * @type {Set<import('./light.js').Light>}
-         * @private
-         */
-        this._lightsSet = new Set();
-
-        /**
-         * Set of light used by clustered lighting (omni and spot, but no directional).
-         *
-         * @type {Set<import('./light.js').Light>}
-         * @private
-         */
-        this._clusteredLightsSet = new Set();
-
         /**
          * Lights separated by light type.
          *
@@ -369,9 +399,14 @@ class Layer {
         this._dirtyLights = false;
         this._dirtyCameras = false;
 
+        // light hash based on the light keys
         this._lightHash = 0;
         this._lightHashDirty = false;
 
+        // light hash based on light ids
+        this._lightIdHash = 0;
+        this._lightIdHashDirty = false;
+
         // #if _PROFILER
         this.skipRenderAfter = Number.MAX_VALUE;
         this._skipRenderCounter = 0;
@@ -382,22 +417,6 @@ class Layer {
         // #endif
 
         this._shaderVersion = -1;
-
-        /**
-         * @type {Float32Array|null}
-         * @ignore
-         */
-        this._lightCube = null;
-    }
-
-    /**
-     * True if the layer contains omni or spot lights
-     *
-     * @type {boolean}
-     * @ignore
-     */
-    get hasClusteredLights() {
-        return this._clusteredLightsSet.size > 0;
     }
 
     /**
@@ -464,6 +483,16 @@ class Layer {
         return this._clearStencilBuffer;
     }
 
+    /**
+     * True if the layer contains omni or spot lights
+     *
+     * @type {boolean}
+     * @ignore
+     */
+    get hasClusteredLights() {
+        return this._clusteredLightsSet.size > 0;
+    }
+
     /**
      * Returns lights used by clustered lighting in a set.
      *
@@ -518,29 +547,31 @@ class Layer {
      * @param {import('./mesh-instance.js').MeshInstance[]} meshInstances - Array of
      * {@link MeshInstance}.
      * @param {boolean} [skipShadowCasters] - Set it to true if you don't want these mesh instances
-     * to cast shadows in this layer.
+     * to cast shadows in this layer. Defaults to false.
      */
     addMeshInstances(meshInstances, skipShadowCasters) {
-        const sceneShaderVer = this._shaderVersion;
-        const casters = this.shadowCasters;
+
         const destMeshInstances = this.meshInstances;
+        const destMeshInstancesSet = this.meshInstancesSet;
 
-        // add mesh instances to the layer's arrays
+        // add mesh instances to the layer's array and the set
         for (let i = 0; i < meshInstances.length; i++) {
             const mi = meshInstances[i];
-            if (destMeshInstances.indexOf(mi) < 0) {
+            if (!destMeshInstancesSet.has(mi)) {
                 destMeshInstances.push(mi);
+                destMeshInstancesSet.add(mi);
                 _tempMaterials.add(mi.material);
             }
+        }
 
-            if (!skipShadowCasters && mi.castShadow && casters.indexOf(mi) < 0) {
-                casters.push(mi);
-                _tempMaterials.add(mi.material);
-            }
+        // shadow casters
+        if (!skipShadowCasters) {
+            this.addShadowCasters(meshInstances);
         }
 
         // clear old shader variants if necessary
         if (_tempMaterials.size > 0) {
+            const sceneShaderVer = this._shaderVersion;
             _tempMaterials.forEach((mat) => {
                 if (sceneShaderVer >= 0 && mat._shaderVersion !== sceneShaderVer)  {
                     // skip this for materials not using variants
@@ -561,28 +592,30 @@ class Layer {
      * @param {import('./mesh-instance.js').MeshInstance[]} meshInstances - Array of
      * {@link MeshInstance}. If they were added to this layer, they will be removed.
      * @param {boolean} [skipShadowCasters] - Set it to true if you want to still cast shadows from
-     * removed mesh instances or if they never did cast shadows before.
+     * removed mesh instances or if they never did cast shadows before. Defaults to false.
      */
     removeMeshInstances(meshInstances, skipShadowCasters) {
 
         const destMeshInstances = this.meshInstances;
-        const casters = this.shadowCasters;
+        const destMeshInstancesSet = this.meshInstancesSet;
 
+        // mesh instances
         for (let i = 0; i < meshInstances.length; i++) {
             const mi = meshInstances[i];
 
             // remove from mesh instances list
-            const j = destMeshInstances.indexOf(mi);
-            if (j >= 0) {
-                destMeshInstances.splice(j, 1);
+            if (destMeshInstancesSet.has(mi)) {
+                destMeshInstancesSet.delete(mi);
+                const j = destMeshInstances.indexOf(mi);
+                if (j >= 0) {
+                    destMeshInstances.splice(j, 1);
+                }
             }
+        }
 
-            // remove from casters
-            if (!skipShadowCasters) {
-                const j = casters.indexOf(mi);
-                if (j >= 0)
-                    casters.splice(j, 1);
-            }
+        // shadow casters
+        if (!skipShadowCasters) {
+            this.removeShadowCasters(meshInstances);
         }
     }
 
@@ -595,13 +628,15 @@ class Layer {
      */
     addShadowCasters(meshInstances) {
         const shadowCasters = this.shadowCasters;
+        const shadowCastersSet = this.shadowCastersSet;
+
         for (let i = 0; i < meshInstances.length; i++) {
-            const m = meshInstances[i];
-            if (m.castShadow && shadowCasters.indexOf(m) < 0) {
-                shadowCasters.push(m);
+            const mi = meshInstances[i];
+            if (mi.castShadow && !shadowCastersSet.has(mi)) {
+                shadowCastersSet.add(mi);
+                shadowCasters.push(mi);
             }
         }
-        this._dirtyLights = true;
     }
 
     /**
@@ -613,11 +648,18 @@ class Layer {
      */
     removeShadowCasters(meshInstances) {
         const shadowCasters = this.shadowCasters;
+        const shadowCastersSet = this.shadowCastersSet;
+
         for (let i = 0; i < meshInstances.length; i++) {
-            const j = shadowCasters.indexOf(meshInstances[i]);
-            if (j >= 0) shadowCasters.splice(j, 1);
+            const mi = meshInstances[i];
+            if (shadowCastersSet.has(mi)) {
+                shadowCastersSet.delete(mi);
+                const j = shadowCasters.indexOf(mi);
+                if (j >= 0) {
+                    shadowCasters.splice(j, 1);
+                }
+            }
         }
-        this._dirtyLights = true;
     }
 
     /**
@@ -628,11 +670,20 @@ class Layer {
      */
     clearMeshInstances(skipShadowCasters = false) {
         this.meshInstances.length = 0;
+        this.meshInstancesSet.clear();
+
         if (!skipShadowCasters) {
             this.shadowCasters.length = 0;
+            this.shadowCastersSet.clear();
         }
     }
 
+    markLightsDirty() {
+        this._dirtyLights = true;
+        this._lightHashDirty = true;
+        this._lightIdHashDirty = true;
+    }
+
     /**
      * Adds a light to this layer.
      *
@@ -647,8 +698,7 @@ class Layer {
             this._lightsSet.add(l);
 
             this._lights.push(l);
-            this._dirtyLights = true;
-            this._lightHashDirty = true;
+            this.markLightsDirty();
         }
 
         if (l.type !== LIGHTTYPE_DIRECTIONAL) {
@@ -669,8 +719,7 @@ class Layer {
             this._lightsSet.delete(l);
 
             this._lights.splice(this._lights.indexOf(l), 1);
-            this._dirtyLights = true;
-            this._lightHashDirty = true;
+            this.markLightsDirty();
         }
 
         if (l.type !== LIGHTTYPE_DIRECTIONAL) {
@@ -685,39 +734,62 @@ class Layer {
         this._lightsSet.clear();
         this._clusteredLightsSet.clear();
         this._lights.length = 0;
-        this._dirtyLights = true;
+        this.markLightsDirty();
     }
 
-    getLightHash(isClustered) {
-        if (this._lightHashDirty) {
-            this._lightHashDirty = false;
+    evaluateLightHash(localLights, directionalLights, useIds) {
 
-            // generate hash to check if layers have the same set of lights independent of their order
-            this._lightHash = 0;
+        let hash = 0;
 
-            const lights = this._lights;
-            if (lights.length > 0) {
-                lights.sort(sortLights);
+        const lights = this._lights;
+        if (lights.length > 0) {
+            lights.sort(useIds ? sortLightsIds : sortLights);
 
-                for (let i = 0; i < lights.length; i++) {
+            for (let i = 0; i < lights.length; i++) {
 
-                    // only directional lights affect the shader generation for clustered lighting
-                    if (isClustered && lights[i].type !== LIGHTTYPE_DIRECTIONAL)
-                        continue;
+                const isLocalLight = lights[i].type !== LIGHTTYPE_DIRECTIONAL;
 
-                    lightKeys.push(lights[i].key);
+                if ((localLights && isLocalLight) || (directionalLights && !isLocalLight)) {
+                    lightKeys.push(useIds ? lights[i].id : lights[i].key);
                 }
+            }
 
-                if (lightKeys.length > 0) {
-                    this._lightHash = hash32Fnv1a(lightKeys);
-                    lightKeys.length = 0;
-                }
+            if (lightKeys.length > 0) {
+                hash = hash32Fnv1a(lightKeys);
+                lightKeys.length = 0;
             }
         }
 
+        return hash;
+    }
+
+
+    getLightHash(isClustered) {
+        if (this._lightHashDirty) {
+            this._lightHashDirty = false;
+
+            // Generate hash to check if layers have the same set of lights independent of their order.
+            // Always use directional lights. Additionally use local lights if clustered lighting is disabled.
+            // (only directional lights affect the shader generation for clustered lighting)
+            this._lightHash = this.evaluateLightHash(!isClustered, true, false);
+        }
+
         return this._lightHash;
     }
 
+    // This is only used in clustered lighting mode
+    getLightIdHash() {
+        if (this._lightIdHashDirty) {
+            this._lightIdHashDirty = false;
+
+            // Generate hash based on Ids of lights sorted by ids, to check if the layers have the same set of lights
+            // Only use local lights (directional lights are not used for clustered lighting)
+            this._lightIdHash = this.evaluateLightHash(true, false, true);
+        }
+
+        return this._lightIdHash;
+    }
+
     /**
      * Adds a camera to this layer.
      *
@@ -793,9 +865,9 @@ class Layer {
     }
 
     /**
-     * @param {boolean} transparent - True if transparent sorting should be used.
      * @param {import('./camera.js').Camera} camera - The camera to sort the visible mesh instances
      * for.
+     * @param {boolean} transparent - True if transparent sorting should be used.
      * @ignore
      */
     sortVisible(camera, transparent) {
diff --git a/src/scene/particle-system/gpu-updater.js b/src/scene/particle-system/gpu-updater.js
index a97450e23..4e5a8759e 100644
--- a/src/scene/particle-system/gpu-updater.js
+++ b/src/scene/particle-system/gpu-updater.js
@@ -140,7 +140,7 @@ class ParticleGPUUpdater {
         this.constantInitialVelocity.setValue(emitter.initialVelocity);
 
         emitterMatrix3.setFromMat4(emitterMatrix);
-        emitterMatrix.invertTo3x3(emitterMatrix3Inv);
+        emitterMatrix3Inv.invertMat4(emitterMatrix);
         this.emitterPosUniform[0] = emitterPos.x;
         this.emitterPosUniform[1] = emitterPos.y;
         this.emitterPosUniform[2] = emitterPos.z;
diff --git a/src/scene/particle-system/particle-emitter.js b/src/scene/particle-system/particle-emitter.js
index 3fd788fcb..7ce356552 100644
--- a/src/scene/particle-system/particle-emitter.js
+++ b/src/scene/particle-system/particle-emitter.js
@@ -287,7 +287,6 @@ class ParticleEmitter {
         this._gpuUpdater = new ParticleGPUUpdater(this, gd);
         this._cpuUpdater = new ParticleCPUUpdater(this);
 
-        this.constantLightCube = gd.scope.resolve('lightCube[0]');
         this.emitterPosUniform = new Float32Array(3);
         this.wrapBoundsUniform = new Float32Array(3);
         this.emitterScaleUniform = new Float32Array([1, 1, 1]);
@@ -314,15 +313,6 @@ class ParticleEmitter {
         setProperty('radialSpeedGraph', default0Curve);
         setProperty('radialSpeedGraph2', this.radialSpeedGraph);
 
-        this.lightCube = new Float32Array(6 * 3);
-        this.lightCubeDir = new Array(6);
-        this.lightCubeDir[0] = new Vec3(-1, 0, 0);
-        this.lightCubeDir[1] = new Vec3(1, 0, 0);
-        this.lightCubeDir[2] = new Vec3(0, -1, 0);
-        this.lightCubeDir[3] = new Vec3(0, 1, 0);
-        this.lightCubeDir[4] = new Vec3(0, 0, -1);
-        this.lightCubeDir[5] = new Vec3(0, 0, 1);
-
         this.animTilesParams = new Float32Array(2);
         this.animParams = new Float32Array(4);
         this.animIndexParams = new Float32Array(2);
diff --git a/src/scene/renderer/forward-renderer.js b/src/scene/renderer/forward-renderer.js
index 2b0d5cfba..709995e9d 100644
--- a/src/scene/renderer/forward-renderer.js
+++ b/src/scene/renderer/forward-renderer.js
@@ -475,7 +475,7 @@ class ForwardRenderer extends Renderer {
         const device = this.device;
         const scene = this.scene;
         const clusteredLightingEnabled = scene.clusteredLightingEnabled;
-        const lightHash = layer ? layer.getLightHash(clusteredLightingEnabled) : 0;
+        const lightHash = layer?.getLightHash(clusteredLightingEnabled) ?? 0;
         let prevMaterial = null, prevObjDefs, prevLightMask;
 
         const drawCallsCount = drawCalls.length;
@@ -1100,6 +1100,12 @@ class ForwardRenderer extends Renderer {
             // add debug mesh instances to visible list
             this.scene.immediate.onPreRenderLayer(layer, visible, transparent);
 
+            // set up layer uniforms
+            if (layer.requiresLightCube) {
+                this.lightCube.update(this.scene.ambientLight, layer._lights);
+                this.constantLightCube.setValue(this.lightCube.colors);
+            }
+
             // upload clustered lights uniforms
             if (clusteredLightingEnabled && renderAction.lightClusters) {
                 renderAction.lightClusters.activate();
diff --git a/src/scene/renderer/renderer.js b/src/scene/renderer/renderer.js
index c95ea2fb6..043975dd4 100644
--- a/src/scene/renderer/renderer.js
+++ b/src/scene/renderer/renderer.js
@@ -13,6 +13,7 @@ import {
 } from '../constants.js';
 import { LightTextureAtlas } from '../lighting/light-texture-atlas.js';
 import { Material } from '../materials/material.js';
+import { LightCube } from '../graphics/light-cube.js';
 
 import {
     CLEARFLAG_COLOR, CLEARFLAG_DEPTH, CLEARFLAG_STENCIL,
@@ -34,6 +35,7 @@ import { ShadowRendererLocal } from './shadow-renderer-local.js';
 import { ShadowRendererDirectional } from './shadow-renderer-directional.js';
 import { CookieRenderer } from './cookie-renderer.js';
 import { ShadowRenderer } from './shadow-renderer.js';
+import { WorldClustersAllocator } from './world-clusters-allocator.js';
 
 let _skinUpdateIndex = 0;
 const boneTextureSize = [0, 0, 0, 0];
@@ -75,9 +77,16 @@ class Renderer {
      * skinning or morphing. Extracted during culling.
      *
      * @type {Set<import('../mesh-instance.js').MeshInstance>}
+     * @private
      */
     processingMeshInstances = new Set();
 
+    /**
+     * @type {WorldClustersAllocator}
+     * @ignore
+     */
+    worldClustersAllocator;
+
     /**
      * Create a new instance.
      *
@@ -90,6 +99,9 @@ class Renderer {
         /** @type {import('../scene.js').Scene|null} */
         this.scene = null;
 
+        // TODO: allocate only when the scene has clustered lighting enabled
+        this.worldClustersAllocator = new WorldClustersAllocator(graphicsDevice);
+
         // texture atlas managing shadow map / cookie texture atlassing for omni and spot lights
         this.lightTextureAtlas = new LightTextureAtlas(graphicsDevice);
 
@@ -158,6 +170,10 @@ class Renderer {
         this.morphPositionTex = scope.resolve('morphPositionTex');
         this.morphNormalTex = scope.resolve('morphNormalTex');
         this.morphTexParams = scope.resolve('morph_tex_params');
+
+        // a single instance of light cube
+        this.lightCube = new LightCube();
+        this.constantLightCube = scope.resolve('lightCube[0]');
     }
 
     destroy() {
@@ -916,8 +932,7 @@ class Renderer {
                 }
 
                 if (light.visibleThisFrame && light.castShadows && light.shadowUpdateMode !== SHADOWUPDATE_NONE) {
-                    const casters = comp._lightCompositionData[i].shadowCastersList;
-                    this._shadowRendererLocal.cull(light, casters);
+                    this._shadowRendererLocal.cull(light, comp);
                 }
             }
         }
@@ -927,12 +942,10 @@ class Renderer {
         const renderActions = comp._renderActions;
         for (let i = 0; i < renderActions.length; i++) {
             const renderAction = renderActions[i];
-            const count = renderAction.directionalLightsIndices.length;
+            const count = renderAction.directionalLights.length;
             for (let j = 0; j < count; j++) {
-                const lightIndex = renderAction.directionalLightsIndices[j];
-                const light = comp._lights[lightIndex];
-                const casters = comp._lightCompositionData[lightIndex].shadowCastersList;
-                this._shadowRendererDirectional.cull(light, casters, renderAction.camera.camera);
+                const light = renderAction.directionalLights[j];
+                this._shadowRendererDirectional.cull(light, comp, renderAction.camera.camera);
             }
         }
     }
@@ -1143,31 +1156,12 @@ class Renderer {
         const startTime = now();
         // #endif
 
-        const emptyWorldClusters = comp.getEmptyWorldClusters(this.device);
-
         const renderActions = comp._renderActions;
-        for (let i = 0; i < renderActions.length; i++) {
-            const renderAction = renderActions[i];
-            const cluster = renderAction.lightClusters;
-
-            if (cluster && cluster !== emptyWorldClusters) {
-
-                // update each cluster only one time
-                if (!_tempSet.has(cluster)) {
-                    _tempSet.add(cluster);
-
-                    const layer = comp.layerList[renderAction.layerIndex];
-                    cluster.update(layer.clusteredLightsSet, this.scene.gammaCorrection, this.scene.lighting);
-                }
-            }
-        }
-
-        // keep temp set empty
-        _tempSet.clear();
+        this.worldClustersAllocator.update(renderActions, this.scene.gammaCorrection, this.scene.lighting);
 
         // #if _PROFILER
         this._lightClustersTime += now() - startTime;
-        this._lightClusters = comp._worldClusters.length;
+        this._lightClusters = this.worldClustersAllocator.count;
         // #endif
     }
 
@@ -1215,7 +1209,7 @@ class Renderer {
         }
 
         // Update static layer data, if something's changed
-        const updated = comp._update(this.device, clusteredLightingEnabled);
+        const updated = comp._update();
 
         // #if _PROFILER
         this._layerCompositionUpdateTime += now() - layerCompositionUpdateTime;
diff --git a/src/scene/renderer/shadow-renderer-directional.js b/src/scene/renderer/shadow-renderer-directional.js
index a2f0ee1ec..98c1da89c 100644
--- a/src/scene/renderer/shadow-renderer-directional.js
+++ b/src/scene/renderer/shadow-renderer-directional.js
@@ -65,7 +65,7 @@ class ShadowRendererDirectional {
     }
 
     // cull directional shadow map
-    cull(light, drawCalls, camera) {
+    cull(light, comp, camera, casters = null) {
 
         // force light visibility if function was manually called
         light.visibleThisFrame = true;
@@ -154,7 +154,7 @@ class ShadowRendererDirectional {
 
             // cull shadow casters
             this.renderer.updateCameraFrustum(shadowCam);
-            this.shadowRenderer.cullShadowCasters(drawCalls, lightRenderData.visibleCasters, shadowCam);
+            this.shadowRenderer.cullShadowCasters(comp, light, lightRenderData.visibleCasters, shadowCam, casters);
 
             // find out AABB of visible shadow casters
             let emptyAabb = true;
diff --git a/src/scene/renderer/shadow-renderer-local.js b/src/scene/renderer/shadow-renderer-local.js
index 3a26b1fc7..9a762e062 100644
--- a/src/scene/renderer/shadow-renderer-local.js
+++ b/src/scene/renderer/shadow-renderer-local.js
@@ -31,7 +31,7 @@ class ShadowRendererLocal {
     }
 
     // cull local shadow map
-    cull(light, drawCalls) {
+    cull(light, comp, casters = null) {
 
         const isClustered = this.renderer.scene.clusteredLightingEnabled;
 
@@ -85,7 +85,7 @@ class ShadowRendererLocal {
 
             // cull shadow casters
             this.renderer.updateCameraFrustum(shadowCam);
-            this.shadowRenderer.cullShadowCasters(drawCalls, lightRenderData.visibleCasters, shadowCam);
+            this.shadowRenderer.cullShadowCasters(comp, light, lightRenderData.visibleCasters, shadowCam, casters);
         }
     }
 
diff --git a/src/scene/renderer/shadow-renderer.js b/src/scene/renderer/shadow-renderer.js
index 53af932cc..f1a42fa0a 100644
--- a/src/scene/renderer/shadow-renderer.js
+++ b/src/scene/renderer/shadow-renderer.js
@@ -47,6 +47,7 @@ function gaussWeights(kernelSize) {
     return values;
 }
 
+const tempSet = new Set();
 const shadowCamView = new Mat4();
 const shadowCamViewProj = new Mat4();
 const pixelOffset = new Float32Array(2);
@@ -155,10 +156,8 @@ class ShadowRenderer {
         shadowCam.clearColorBuffer = !hwPcf;
     }
 
-    // culls the list of meshes instances by the camera, storing visible mesh instances in the specified array
-    cullShadowCasters(meshInstances, visible, camera) {
+    _cullShadowCastersInternal(meshInstances, visible, camera) {
 
-        let count = 0;
         const numInstances = meshInstances.length;
         for (let i = 0; i < numInstances; i++) {
             const meshInstance = meshInstances[i];
@@ -166,13 +165,54 @@ class ShadowRenderer {
             if (meshInstance.castShadow) {
                 if (!meshInstance.cull || meshInstance._isVisible(camera)) {
                     meshInstance.visibleThisFrame = true;
-                    visible[count] = meshInstance;
-                    count++;
+                    visible.push(meshInstance);
                 }
             }
         }
+    }
+
+    /**
+     * Culls the list of shadow casters used by the light by the camera, storing visible mesh
+     * instances in the specified array.
+     * @param {import('../composition/layer-composition.js').LayerComposition} comp - The layer
+     * composition used as a source of shadow casters, if those are not provided directly.
+     * @param {import('../light.js').Light} light - The light.
+     * @param {import('../mesh-instance.js').MeshInstance[]} visible - The array to store visible
+     * mesh instances in.
+     * @param {import('../camera.js').Camera} camera - The camera.
+     * @param {import('../mesh-instance.js').MeshInstance[]} [casters] - Optional array of mesh
+     * instances to use as casters.
+     * @ignore
+     */
+    cullShadowCasters(comp, light, visible, camera, casters) {
+
+        visible.length = 0;
+
+        // if the casters are supplied, use them
+        if (casters) {
+
+            this._cullShadowCastersInternal(casters, visible, camera);
 
-        visible.length = count;
+        } else {    // otherwise, get them from the layer composition
+
+            // for each layer
+            const layers = comp.layerList;
+            const len = layers.length;
+            for (let i = 0; i < len; i++) {
+                const layer = layers[i];
+                if (layer._lightsSet.has(light)) {
+
+                    // layer can be in the list two times (opaque, transp), add casters only one time
+                    if (!tempSet.has(layer)) {
+                        tempSet.add(layer);
+
+                        this._cullShadowCastersInternal(layer.shadowCasters, visible, camera);
+                    }
+                }
+            }
+
+            tempSet.clear();
+        }
 
         // TODO: we should probably sort shadow meshes by shader and not depth
         visible.sort(this.renderer.sortCompareDepth);
diff --git a/src/scene/renderer/world-clusters-allocator.js b/src/scene/renderer/world-clusters-allocator.js
new file mode 100644
index 000000000..fad029019
--- /dev/null
+++ b/src/scene/renderer/world-clusters-allocator.js
@@ -0,0 +1,144 @@
+import { DebugHelper } from '../../core/debug.js';
+import { WorldClusters } from '../lighting/world-clusters.js';
+
+const tempClusterArray = [];
+
+/**
+ * A class managing instances of world clusters used by the renderer for layers with
+ * unique sets of clustered lights.
+ *
+ * @ignore
+ */
+class WorldClustersAllocator {
+    /**
+     * Empty cluster with no lights.
+     *
+     * @type {WorldClusters|null}
+     */
+    _empty = null;
+
+    /**
+     * All allocated clusters
+     *
+     * @type {WorldClusters[]}
+     */
+    _allocated = [];
+
+    /**
+     * Render actions with all unique light clusters. The key is the hash of lights on a layer, the
+     * value is a render action with unique light clusters.
+     *
+     * @type {Map<number, import('../composition/render-action.js').RenderAction>}
+     */
+    _clusters = new Map();
+
+    /**
+     * Create a new instance.
+     *
+     * @param {import('../../platform/graphics/graphics-device.js').GraphicsDevice} graphicsDevice -
+     * The graphics device.
+     */
+    constructor(graphicsDevice) {
+        this.device = graphicsDevice;
+    }
+
+    destroy() {
+
+        // empty light cluster
+        if (this._empty) {
+            this._empty.destroy();
+            this._empty = null;
+        }
+
+        // all other clusters
+        this._allocated.forEach((cluster) => {
+            cluster.destroy();
+        });
+        this._allocated.length = 0;
+    }
+
+    get count() {
+        return this._allocated.length;
+    }
+
+    // returns an empty light cluster object to be used when no lights are used
+    get empty() {
+        if (!this._empty) {
+
+            // create cluster structure with no lights
+            const empty = new WorldClusters(this.device);
+            empty.name = 'ClusterEmpty';
+
+            // update it once to avoid doing it each frame
+            empty.update([], false, null);
+            this._empty = empty;
+        }
+
+        return this._empty;
+    }
+
+    // assign light clusters to render actions that need it
+    assign(renderActions) {
+
+        const empty = this.empty;
+
+        // reuse previously allocated clusters
+        tempClusterArray.push(...this._allocated);
+        this._allocated.length = 0;
+        this._clusters.clear();
+
+        // process all render actions
+        const count = renderActions.length;
+        for (let i = 0; i < count; i++) {
+            const ra = renderActions[i];
+            ra.lightClusters = null;
+
+            // if the layer has lights used by clusters, and meshes
+            const layer = ra.layer;
+            if (layer.hasClusteredLights && layer.meshInstances.length) {
+
+                // use existing clusters if the lights on the layer are the same
+                const hash = layer.getLightIdHash();
+                const existingRenderAction = this._clusters.get(hash);
+                let clusters = existingRenderAction?.lightClusters;
+
+                // no match, needs new clusters
+                if (!clusters) {
+
+                    // use already allocated cluster from last frame, or create a new one
+                    clusters = tempClusterArray.pop() ?? new WorldClusters(this.device);
+                    DebugHelper.setName(clusters, `Cluster-${this._allocated.length}`);
+
+                    this._allocated.push(clusters);
+                    this._clusters.set(hash, ra);
+                }
+
+                ra.lightClusters = clusters;
+            }
+
+            // no clustered lights, use the cluster with no lights
+            if (!ra.lightClusters) {
+                ra.lightClusters = empty;
+            }
+        }
+
+        // delete leftovers
+        tempClusterArray.forEach(item => item.destroy());
+        tempClusterArray.length = 0;
+    }
+
+    update(renderActions, gammaCorrection, lighting) {
+
+        // assign clusters to render actions
+        this.assign(renderActions);
+
+        // update all unique clusters
+        this._clusters.forEach((renderAction) => {
+            const layer = renderAction.layer;
+            const cluster = renderAction.lightClusters;
+            cluster.update(layer.clusteredLightsSet, gammaCorrection, lighting);
+        });
+    }
+}
+
+export { WorldClustersAllocator };
diff --git a/src/scene/scene.js b/src/scene/scene.js
index c9d44179e..df5df210c 100644
--- a/src/scene/scene.js
+++ b/src/scene/scene.js
@@ -617,7 +617,7 @@ class Scene extends EventHandler {
                 this._skyboxRotationMat3.setIdentity();
             } else {
                 this._skyboxRotationMat4.setTRS(Vec3.ZERO, value, Vec3.ONE);
-                this._skyboxRotationMat4.invertTo3x3(this._skyboxRotationMat3);
+                this._skyboxRotationMat3.invertMat4(this._skyboxRotationMat4);
             }
 
             // only reset sky / rebuild scene shaders if rotation changed away from identity for the first time
diff --git a/src/scene/shader-lib/chunks/chunk-validation.js b/src/scene/shader-lib/chunks/chunk-validation.js
index 20c13aa49..5b852989c 100644
--- a/src/scene/shader-lib/chunks/chunk-validation.js
+++ b/src/scene/shader-lib/chunks/chunk-validation.js
@@ -43,15 +43,11 @@ const chunkVersions = {
     combinePS: CHUNKAPI_1_62,
     falloffInvSquaredPS: CHUNKAPI_1_62,
     falloffLinearPS: CHUNKAPI_1_62,
-    fresnelSchlickPS: CHUNKAPI_1_62,
-    iridescenceDiffractionPS: CHUNKAPI_1_62,
     lightDiffuseLambertPS: CHUNKAPI_1_62,
     lightSheenPS: CHUNKAPI_1_62,
     lightSpecularAnisoGGXPS: CHUNKAPI_1_62,
     lightSpecularBlinnPS: CHUNKAPI_1_62,
     lightSpecularPhongPS: CHUNKAPI_1_62,
-    lightmapAddPS: CHUNKAPI_1_62,
-    lightmapDirAddPS: CHUNKAPI_1_62,
     ltcPS: CHUNKAPI_1_62,
     reflDirPS: CHUNKAPI_1_62,
     reflDirAnisoPS: CHUNKAPI_1_62,
@@ -62,8 +58,6 @@ const chunkVersions = {
     reflectionSheenPS: CHUNKAPI_1_62,
     reflectionSpherePS: CHUNKAPI_1_62,
     reflectionSphereLowPS: CHUNKAPI_1_62,
-    refractionCubePS: CHUNKAPI_1_62,
-    refractionDynamicPS: CHUNKAPI_1_62,
     shadowCommonPS: CHUNKAPI_1_62,
     shadowCoordPS: CHUNKAPI_1_62,
     shadowCoordPerspZBufferPS: CHUNKAPI_1_62,
@@ -81,7 +75,13 @@ const chunkVersions = {
     endPS: CHUNKAPI_1_65,
     metalnessModulatePS: CHUNKAPI_1_65,
     outputAlphaPS: CHUNKAPI_1_65,
-    outputAlphaPremulPS: CHUNKAPI_1_65
+    outputAlphaPremulPS: CHUNKAPI_1_65,
+    fresnelSchlickPS: CHUNKAPI_1_65,
+    iridescenceDiffractionPS: CHUNKAPI_1_65,
+    lightmapAddPS: CHUNKAPI_1_65,
+    lightmapDirAddPS: CHUNKAPI_1_65,
+    refractionCubePS: CHUNKAPI_1_65,
+    refractionDynamicPS: CHUNKAPI_1_65
 };
 
 // removed
diff --git a/src/scene/shader-lib/chunks/chunks.js b/src/scene/shader-lib/chunks/chunks.js
index d5527a3be..b37b1ce98 100644
--- a/src/scene/shader-lib/chunks/chunks.js
+++ b/src/scene/shader-lib/chunks/chunks.js
@@ -202,11 +202,9 @@ import webgpuPS from '../../../platform/graphics/shader-chunks/frag/webgpu.js';
 import webgpuVS from '../../../platform/graphics/shader-chunks/vert/webgpu.js';
 
 /**
- * @static
- * @readonly
+ * Object containing all default shader chunks used by shader generators.
+ *
  * @type {object}
- * @name shaderChunks
- * @description Object containing all default shader chunks used by shader generators.
  */
 const shaderChunks = {
     alphaTestPS,
diff --git a/src/scene/shader-lib/chunks/lit/frag/fresnelSchlick.js b/src/scene/shader-lib/chunks/lit/frag/fresnelSchlick.js
index 7b77b3bc8..608a13c31 100644
--- a/src/scene/shader-lib/chunks/lit/frag/fresnelSchlick.js
+++ b/src/scene/shader-lib/chunks/lit/frag/fresnelSchlick.js
@@ -6,14 +6,14 @@ vec3 getFresnel(
         vec3 specularity
 #if defined(LIT_IRIDESCENCE)
         , vec3 iridescenceFresnel, 
-        IridescenceArgs iridescence
+        float iridescenceIntensity
 #endif
     ) {
     float fresnel = pow(1.0 - max(cosTheta, 0.0), 5.0);
     float glossSq = gloss * gloss;
     vec3 ret = specularity + (max(vec3(glossSq), specularity) - specularity) * fresnel;
 #if defined(LIT_IRIDESCENCE)
-    return mix(ret, iridescenceFresnel, iridescence.intensity);
+    return mix(ret, iridescenceFresnel, iridescenceIntensity);
 #else
     return ret;
 #endif    
diff --git a/src/scene/shader-lib/chunks/lit/frag/iridescenceDiffraction.js b/src/scene/shader-lib/chunks/lit/frag/iridescenceDiffraction.js
index 16668deba..6bd68712c 100644
--- a/src/scene/shader-lib/chunks/lit/frag/iridescenceDiffraction.js
+++ b/src/scene/shader-lib/chunks/lit/frag/iridescenceDiffraction.js
@@ -98,7 +98,7 @@ vec3 calcIridescence(float outsideIor, float cosTheta, vec3 base_f0, float iride
     return max(i, vec3(0.0));
 }
 
-vec3 getIridescence(float cosTheta, vec3 specularity, inout IridescenceArgs iridescence) {
-    return calcIridescence(1.0, cosTheta, specularity, iridescence.thickness);
+vec3 getIridescence(float cosTheta, vec3 specularity, float iridescenceThickness) {
+    return calcIridescence(1.0, cosTheta, specularity, iridescenceThickness);
 }
 `;
diff --git a/src/scene/shader-lib/chunks/lit/frag/lightmapAdd.js b/src/scene/shader-lib/chunks/lit/frag/lightmapAdd.js
index 548442664..7cdea51f0 100644
--- a/src/scene/shader-lib/chunks/lit/frag/lightmapAdd.js
+++ b/src/scene/shader-lib/chunks/lit/frag/lightmapAdd.js
@@ -11,7 +11,7 @@ void addLightMap(
     mat3 tbn
 #if defined(LIT_IRIDESCENCE)
     vec3 iridescenceFresnel, 
-    IridescenceArgs iridescence
+    float iridescenceIntensity
 #endif
 ) {
     dDiffuseLight += lightmap;
diff --git a/src/scene/shader-lib/chunks/lit/frag/lightmapDirAdd.js b/src/scene/shader-lib/chunks/lit/frag/lightmapDirAdd.js
index d8eca0b07..0443f11b0 100644
--- a/src/scene/shader-lib/chunks/lit/frag/lightmapDirAdd.js
+++ b/src/scene/shader-lib/chunks/lit/frag/lightmapDirAdd.js
@@ -11,7 +11,7 @@ void addLightMap(
     mat3 tbn
 #if defined(LIT_IRIDESCENCE)
     vec3 iridescenceFresnel, 
-    IridescenceArgs iridescence
+    float iridescenceIntensity
 #endif
 ) {
     if (dot(dir, dir) < 0.0001) {
@@ -33,7 +33,7 @@ void addLightMap(
             specularity
         #if defined(LIT_IRIDESCENCE)
             , iridescenceFresnel,
-            iridescence
+            iridescenceIntensity
         #endif
             );
 #endif
diff --git a/src/scene/shader-lib/chunks/lit/frag/refractionCube.js b/src/scene/shader-lib/chunks/lit/frag/refractionCube.js
index 363c6d0ac..7ad217849 100644
--- a/src/scene/shader-lib/chunks/lit/frag/refractionCube.js
+++ b/src/scene/shader-lib/chunks/lit/frag/refractionCube.js
@@ -17,7 +17,7 @@ void addRefraction(
     float refractionIndex
 #if defined(LIT_IRIDESCENCE)
     , vec3 iridescenceFresnel,
-    IridescenceArgs iridescence
+    float iridescenceIntensity
 #endif 
 ) {
     // use same reflection code with refraction vector
diff --git a/src/scene/shader-lib/chunks/lit/frag/refractionDynamic.js b/src/scene/shader-lib/chunks/lit/frag/refractionDynamic.js
index b296ef05b..af1f8798f 100644
--- a/src/scene/shader-lib/chunks/lit/frag/refractionDynamic.js
+++ b/src/scene/shader-lib/chunks/lit/frag/refractionDynamic.js
@@ -13,7 +13,7 @@ void addRefraction(
     float refractionIndex
 #if defined(LIT_IRIDESCENCE)
     , vec3 iridescenceFresnel,
-    IridescenceArgs iridescence
+    float iridescenceIntensity
 #endif
 ) {
 
@@ -64,7 +64,7 @@ void addRefraction(
             specularity
         #if defined(LIT_IRIDESCENCE)
             , iridescenceFresnel,
-            iridescence
+            iridescenceIntensity
         #endif
         );
     dDiffuseLight = mix(dDiffuseLight, refraction * transmittance * fresnel, transmission);
diff --git a/src/scene/shader-lib/program-library.js b/src/scene/shader-lib/program-library.js
index dd58d8630..bb003a3d6 100644
--- a/src/scene/shader-lib/program-library.js
+++ b/src/scene/shader-lib/program-library.js
@@ -27,13 +27,19 @@ class ProgramLibrary {
     /**
      * A cache of shader definitions before processing.
      *
-     * @type {Map<string, object>}
+     * @type {Map<number, object>}
      */
     definitionsCache = new Map();
 
+    /**
+     * Named shader generators.
+     *
+     * @type {Map<string, import('./programs/shader-generator.js').ShaderGenerator>}
+     */
+    _generators = new Map();
+
     constructor(device, standardMaterial) {
         this._device = device;
-        this._generators = {};
         this._isClearingCache = false;
         this._precached = false;
 
@@ -57,22 +63,31 @@ class ProgramLibrary {
     }
 
     register(name, generator) {
-        if (!this.isRegistered(name)) {
-            this._generators[name] = generator;
+        if (!this._generators.has(name)) {
+            this._generators.set(name, generator);
         }
     }
 
     unregister(name) {
-        if (this.isRegistered(name)) {
-            delete this._generators[name];
+        if (this._generators.has(name)) {
+            this._generators.delete(name);
         }
     }
 
     isRegistered(name) {
-        const generator = this._generators[name];
-        return (generator !== undefined);
+        return this._generators.has(name);
     }
 
+    /**
+     * Returns a generated shader definition for the specified options. They key is used to cache the
+     * shader definition.
+     * @param {import('./programs/shader-generator.js').ShaderGenerator} generator - The generator
+     * to use.
+     * @param {string} name - The unique name of the shader generator.
+     * @param {number} key - A unique key representing the shader options.
+     * @param {object} options - The shader options.
+     * @returns {object} - The shader definition.
+     */
     generateShaderDefinition(generator, name, key, options) {
         let def = this.definitionsCache.get(key);
         if (!def) {
@@ -112,7 +127,7 @@ class ProgramLibrary {
     }
 
     getProgram(name, options, processingOptions, userMaterialId) {
-        const generator = this._generators[name];
+        const generator = this._generators.get(name);
         if (!generator) {
             Debug.warn(`ProgramLibrary#getProgram: No program library functions registered for: ${name}`);
             return null;
diff --git a/src/scene/shader-lib/programs/basic.js b/src/scene/shader-lib/programs/basic.js
index a49abf224..4566f5805 100644
--- a/src/scene/shader-lib/programs/basic.js
+++ b/src/scene/shader-lib/programs/basic.js
@@ -9,10 +9,10 @@ import {
 } from '../../constants.js';
 import { ShaderPass } from '../../shader-pass.js';
 
-import { begin, end, fogCode, skinCode } from './common.js';
+import { ShaderGenerator } from './shader-generator.js';
 
-const basic = {
-    generateKey: function (options) {
+class ShaderGeneratorBasic extends ShaderGenerator {
+    generateKey(options) {
         let key = 'basic';
         if (options.fog)                    key += '_fog';
         if (options.alphaTest)              key += '_atst';
@@ -28,9 +28,9 @@ const basic = {
 
         key += '_' + options.pass;
         return key;
-    },
+    }
 
-    createShaderDefinition: function (device, options) {
+    createShaderDefinition(device, options) {
         // GENERATE ATTRIBUTES
         const attributes = {
             vertex_position: SEMANTIC_POSITION
@@ -56,7 +56,7 @@ const basic = {
         vshader += shaderChunks.transformDeclVS;
 
         if (options.skin) {
-            vshader += skinCode(device);
+            vshader += ShaderGenerator.skinCode(device);
             vshader += shaderChunks.transformSkinnedVS;
         } else {
             vshader += shaderChunks.transformVS;
@@ -84,7 +84,7 @@ const basic = {
         }
 
         // VERTEX SHADER BODY
-        vshader += begin();
+        vshader += ShaderGenerator.begin();
 
         vshader += "   gl_Position = getPosition();\n";
 
@@ -99,7 +99,7 @@ const basic = {
             vshader += '    vUv0 = vertex_texCoord0;\n';
         }
 
-        vshader += end();
+        vshader += ShaderGenerator.end();
 
         // GENERATE FRAGMENT SHADER
         let fshader = shaderPassDefines;
@@ -115,7 +115,7 @@ const basic = {
             fshader += 'uniform sampler2D texture_diffuseMap;\n';
         }
         if (options.fog) {
-            fshader += fogCode(options.fog);
+            fshader += ShaderGenerator.fogCode(options.fog);
         }
         if (options.alphaTest) {
             fshader += shaderChunks.alphaTestPS;
@@ -128,7 +128,7 @@ const basic = {
         }
 
         // FRAGMENT SHADER BODY
-        fshader += begin();
+        fshader += ShaderGenerator.begin();
 
         // Read the map texels that the shader needs
         if (options.vertexColors) {
@@ -156,7 +156,7 @@ const basic = {
             }
         }
 
-        fshader += end();
+        fshader += ShaderGenerator.end();
 
         return ShaderUtils.createDefinition(device, {
             name: 'BasicShader',
@@ -165,6 +165,8 @@ const basic = {
             fragmentCode: fshader
         });
     }
-};
+}
+
+const basic = new ShaderGeneratorBasic();
 
 export { basic };
diff --git a/src/scene/shader-lib/programs/common.js b/src/scene/shader-lib/programs/common.js
deleted file mode 100644
index f2523f372..000000000
--- a/src/scene/shader-lib/programs/common.js
+++ /dev/null
@@ -1,62 +0,0 @@
-import {
-    GAMMA_SRGB, GAMMA_SRGBFAST, GAMMA_SRGBHDR,
-    TONEMAP_ACES, TONEMAP_ACES2, TONEMAP_FILMIC, TONEMAP_HEJL, TONEMAP_LINEAR
-} from '../../constants.js';
-
-import { shaderChunks } from '../chunks/chunks.js';
-
-function gammaCode(value, chunks) {
-    if (!chunks) chunks = shaderChunks;
-    if (value === GAMMA_SRGB || value === GAMMA_SRGBFAST) {
-        return chunks.gamma2_2PS ? chunks.gamma2_2PS : shaderChunks.gamma2_2PS;
-    } else if (value === GAMMA_SRGBHDR) {
-        return "#define HDR\n" + (chunks.gamma2_2PS ? chunks.gamma2_2PS : shaderChunks.gamma2_2PS);
-    }
-    return chunks.gamma1_0PS ? chunks.gamma1_0PS : shaderChunks.gamma1_0PS;
-}
-
-function tonemapCode(value, chunks) {
-    if (!chunks) chunks = shaderChunks;
-    if (value === TONEMAP_FILMIC) {
-        return chunks.tonemappingFilmicPS ? chunks.tonemappingFilmicPS : shaderChunks.tonemappingFilmicPS;
-    } else if (value === TONEMAP_LINEAR) {
-        return chunks.tonemappingLinearPS ? chunks.tonemappingLinearPS : shaderChunks.tonemappingLinearPS;
-    } else if (value === TONEMAP_HEJL) {
-        return chunks.tonemappingHejlPS ? chunks.tonemappingHejlPS : shaderChunks.tonemappingHejlPS;
-    } else if (value === TONEMAP_ACES) {
-        return chunks.tonemappingAcesPS ? chunks.tonemappingAcesPS : shaderChunks.tonemappingAcesPS;
-    } else if (value === TONEMAP_ACES2) {
-        return chunks.tonemappingAces2PS ? chunks.tonemappingAces2PS : shaderChunks.tonemappingAces2PS;
-    }
-    return chunks.tonemapingNonePS ? chunks.tonemapingNonePS : shaderChunks.tonemappingNonePS;
-}
-
-function fogCode(value, chunks) {
-    if (!chunks) chunks = shaderChunks;
-    if (value === 'linear') {
-        return chunks.fogLinearPS ? chunks.fogLinearPS : shaderChunks.fogLinearPS;
-    } else if (value === 'exp') {
-        return chunks.fogExpPS ? chunks.fogExpPS : shaderChunks.fogExpPS;
-    } else if (value === 'exp2') {
-        return chunks.fogExp2PS ? chunks.fogExp2PS : shaderChunks.fogExp2PS;
-    }
-    return chunks.fogNonePS ? chunks.fogNonePS : shaderChunks.fogNonePS;
-}
-
-function skinCode(device, chunks) {
-    if (!chunks) chunks = shaderChunks;
-    if (device.supportsBoneTextures) {
-        return chunks.skinTexVS;
-    }
-    return "#define BONE_LIMIT " + device.getBoneLimit() + "\n" + chunks.skinConstVS;
-}
-
-function begin() {
-    return 'void main(void)\n{\n';
-}
-
-function end() {
-    return '}\n';
-}
-
-export { begin, end, fogCode, gammaCode, skinCode, tonemapCode };
diff --git a/src/scene/shader-lib/programs/lit-shader.js b/src/scene/shader-lib/programs/lit-shader.js
index eab3ed139..bedd6c172 100644
--- a/src/scene/shader-lib/programs/lit-shader.js
+++ b/src/scene/shader-lib/programs/lit-shader.js
@@ -23,10 +23,10 @@ import {
 import { LightsBuffer } from '../../lighting/lights-buffer.js';
 import { ShaderPass } from '../../shader-pass.js';
 
-import { begin, end, fogCode, gammaCode, skinCode, tonemapCode } from './common.js';
 import { validateUserChunks } from '../chunks/chunk-validation.js';
 import { ShaderUtils } from '../../../platform/graphics/shader-utils.js';
 import { ChunkBuilder } from '../chunk-builder.js';
+import { ShaderGenerator } from './shader-generator.js';
 
 const builtinAttributes = {
     vertex_normal: SEMANTIC_NORMAL,
@@ -363,7 +363,7 @@ class LitShader {
         if (options.skin) {
             this.attributes.vertex_boneWeights = SEMANTIC_BLENDWEIGHT;
             this.attributes.vertex_boneIndices = SEMANTIC_BLENDINDICES;
-            code += skinCode(device, chunks);
+            code += ShaderGenerator.skinCode(device, chunks);
             code += "#define SKIN\n";
         } else if (options.useInstancing) {
             code += "#define INSTANCING\n";
@@ -417,10 +417,10 @@ class LitShader {
         code += this.varyingDefines;
         code += this.frontendDecl;
         code += this.frontendCode;
-        code += begin();
+        code += ShaderGenerator.begin();
         code += this.frontendFunc;
         code += "    gl_FragColor = uColor;\n";
-        code += end();
+        code += ShaderGenerator.end();
         return code;
     }
 
@@ -435,10 +435,10 @@ class LitShader {
         code += chunks.packDepthPS;
         code += this.frontendDecl;
         code += this.frontendCode;
-        code += begin();
+        code += ShaderGenerator.begin();
         code += this.frontendFunc;
         code += "    gl_FragColor = packFloat(vDepth);\n";
-        code += end();
+        code += ShaderGenerator.end();
 
         return code;
     }
@@ -503,7 +503,7 @@ class LitShader {
             code += shaderChunks.linearizeDepthPS;
         }
 
-        code += begin();
+        code += ShaderGenerator.begin();
 
         code += this.frontendFunc;
 
@@ -552,7 +552,7 @@ class LitShader {
             code += chunks.storeEVSMPS;
         }
 
-        code += end();
+        code += ShaderGenerator.end();
 
         return code;
     }
@@ -737,9 +737,9 @@ class LitShader {
         // FIXME: only add these when needed
         func.append(chunks.sphericalPS);
         func.append(chunks.decodePS);
-        func.append(gammaCode(options.gamma, chunks));
-        func.append(tonemapCode(options.toneMap, chunks));
-        func.append(fogCode(options.fog, chunks));
+        func.append(ShaderGenerator.gammaCode(options.gamma, chunks));
+        func.append(ShaderGenerator.tonemapCode(options.toneMap, chunks));
+        func.append(ShaderGenerator.fogCode(options.fog, chunks));
 
         // frontend
         func.append(this.frontendCode);
@@ -1024,7 +1024,7 @@ class LitShader {
             }
 
             if (options.useIridescence) {
-                backend.append("    vec3 iridescenceFresnel = getIridescence(saturate(dot(dViewDirW, litArgs_worldNormal)), litArgs_specularity, litArgs_iridescence);");
+                backend.append("    vec3 iridescenceFresnel = getIridescence(saturate(dot(dViewDirW, litArgs_worldNormal)), litArgs_specularity, litArgs_iridescence_thickness);");
             }
         }
 
@@ -1064,7 +1064,7 @@ class LitShader {
                 dTBN
             #if defined(LIT_IRIDESCENCE)
                 , iridescenceFresnel,
-                litArgs_iridescence
+                litArgs_iridescence_intensity
             #endif
                 );`);
         }
@@ -1099,7 +1099,7 @@ class LitShader {
                             litArgs_specularity
                         #if defined(LIT_IRIDESCENCE)
                             , iridescenceFresnel,
-                            litArgs_iridescence
+                            litArgs_iridescence_intensity
                         #endif
                             );`);
                 } else {
@@ -1375,7 +1375,7 @@ class LitShader {
                                         litArgs_specularity
                                     #if defined(LIT_IRIDESCENCE)
                                         , iridescenceFresnel, 
-                                        litArgs_iridescence
+                                        litArgs_iridescence_intensity
                                     #endif
                                     );` : `* litArgs_specularity;`));
                         }
@@ -1435,7 +1435,7 @@ class LitShader {
                         litArgs_ior
                     #if defined(LIT_IRIDESCENCE)
                         , iridescenceFresnel, 
-                        litArgs_iridescence
+                        litArgs_iridescence_intensity
                     #endif
                     );`);
             }
@@ -1504,7 +1504,7 @@ class LitShader {
 
         code.append("    evaluateBackend();");
 
-        code.append(end());
+        code.append(ShaderGenerator.end());
 
         const mergedCode = decl.code + func.code + code.code;
 
diff --git a/src/scene/shader-lib/programs/lit.js b/src/scene/shader-lib/programs/lit.js
index 637017d52..378daf988 100644
--- a/src/scene/shader-lib/programs/lit.js
+++ b/src/scene/shader-lib/programs/lit.js
@@ -1,16 +1,12 @@
 import { ChunkBuilder } from '../chunk-builder.js';
 import { LitShader } from './lit-shader.js';
 import { LitOptionsUtils } from './lit-options-utils.js';
+import { ShaderGenerator } from './shader-generator.js';
 
 const dummyUvs = [0, 1, 2, 3, 4, 5, 6, 7];
 
-/**
- * @ignore
- */
-const lit = {
-
-    /** @type {Function} */
-    generateKey: function (options) {
+class ShaderGeneratorLit extends ShaderGenerator {
+    generateKey(options) {
         const key = "lit" +
             dummyUvs.map((dummy, index) => {
                 return options.usedUvs[index] ? "1" : "0";
@@ -19,7 +15,7 @@ const lit = {
             LitOptionsUtils.generateKey(options.litOptions);
 
         return key;
-    },
+    }
 
     /**
      * @param {import('../../../platform/graphics/graphics-device.js').GraphicsDevice} device - The
@@ -28,7 +24,7 @@ const lit = {
      * @returns {object} Returns the created shader definition.
      * @ignore
      */
-    createShaderDefinition: function (device, options) {
+    createShaderDefinition(device, options) {
         const litShader = new LitShader(device, options.litOptions);
 
         const decl = new ChunkBuilder();
@@ -50,6 +46,8 @@ const lit = {
 
         return litShader.getDefinition();
     }
-};
+}
+
+const lit = new ShaderGeneratorLit();
 
 export { lit };
diff --git a/src/scene/shader-lib/programs/particle.js b/src/scene/shader-lib/programs/particle.js
index d57f8f24b..288378be2 100644
--- a/src/scene/shader-lib/programs/particle.js
+++ b/src/scene/shader-lib/programs/particle.js
@@ -1,11 +1,10 @@
 import { ShaderUtils } from '../../../platform/graphics/shader-utils.js';
 import { BLEND_ADDITIVE, BLEND_MULTIPLICATIVE, BLEND_NORMAL } from '../../constants.js';
 import { shaderChunks } from '../chunks/chunks.js';
+import { ShaderGenerator } from './shader-generator.js';
 
-import { gammaCode, tonemapCode } from './common.js';
-
-const particle = {
-    generateKey: function (options) {
+class ShaderGeneratorParticle extends ShaderGenerator {
+    generateKey(options) {
         let key = "particle";
         for (const prop in options) {
             if (options.hasOwnProperty(prop)) {
@@ -13,16 +12,16 @@ const particle = {
             }
         }
         return key;
-    },
+    }
 
-    _animTex: function (options) {
+    _animTex(options) {
         let vshader = "";
         vshader += options.animTexLoop ? shaderChunks.particleAnimFrameLoopVS : shaderChunks.particleAnimFrameClampVS;
         vshader += shaderChunks.particleAnimTexVS;
         return vshader;
-    },
+    }
 
-    createShaderDefinition: function (device, options) {
+    createShaderDefinition(device, options) {
 
         const executionDefine = `#define PARTICLE_${options.useCpu ? 'CPU' : 'GPU'}\n`;
 
@@ -85,8 +84,8 @@ const particle = {
 
         if ((options.normal === 0) && (options.fog === "none")) options.srgb = false; // don't have to perform all gamma conversions when no lighting and fogging is used
         fshader += shaderChunks.decodePS;
-        fshader += gammaCode(options.gamma);
-        fshader += tonemapCode(options.toneMap);
+        fshader += ShaderGenerator.gammaCode(options.gamma);
+        fshader += ShaderGenerator.tonemapCode(options.toneMap);
 
         if (options.fog === 'linear') {
             fshader += shaderChunks.fogLinearPS;
@@ -121,6 +120,8 @@ const particle = {
             fragmentCode: fshader
         });
     }
-};
+}
+
+const particle = new ShaderGeneratorParticle();
 
 export { particle };
diff --git a/src/scene/shader-lib/programs/shader-generator.js b/src/scene/shader-lib/programs/shader-generator.js
new file mode 100644
index 000000000..6b6e5981e
--- /dev/null
+++ b/src/scene/shader-lib/programs/shader-generator.js
@@ -0,0 +1,63 @@
+import {
+    GAMMA_SRGB, GAMMA_SRGBFAST, GAMMA_SRGBHDR,
+    TONEMAP_ACES, TONEMAP_ACES2, TONEMAP_FILMIC, TONEMAP_HEJL, TONEMAP_LINEAR
+} from '../../constants.js';
+import { shaderChunks } from '../chunks/chunks.js';
+
+class ShaderGenerator {
+    static begin() {
+        return 'void main(void)\n{\n';
+    }
+
+    static end() {
+        return '}\n';
+    }
+
+    static skinCode(device, chunks) {
+        if (!chunks) chunks = shaderChunks;
+        if (device.supportsBoneTextures) {
+            return chunks.skinTexVS;
+        }
+        return "#define BONE_LIMIT " + device.getBoneLimit() + "\n" + chunks.skinConstVS;
+    }
+
+    static fogCode(value, chunks) {
+        if (!chunks) chunks = shaderChunks;
+        if (value === 'linear') {
+            return chunks.fogLinearPS ? chunks.fogLinearPS : shaderChunks.fogLinearPS;
+        } else if (value === 'exp') {
+            return chunks.fogExpPS ? chunks.fogExpPS : shaderChunks.fogExpPS;
+        } else if (value === 'exp2') {
+            return chunks.fogExp2PS ? chunks.fogExp2PS : shaderChunks.fogExp2PS;
+        }
+        return chunks.fogNonePS ? chunks.fogNonePS : shaderChunks.fogNonePS;
+    }
+
+    static gammaCode(value, chunks) {
+        if (!chunks) chunks = shaderChunks;
+        if (value === GAMMA_SRGB || value === GAMMA_SRGBFAST) {
+            return chunks.gamma2_2PS ? chunks.gamma2_2PS : shaderChunks.gamma2_2PS;
+        } else if (value === GAMMA_SRGBHDR) {
+            return "#define HDR\n" + (chunks.gamma2_2PS ? chunks.gamma2_2PS : shaderChunks.gamma2_2PS);
+        }
+        return chunks.gamma1_0PS ? chunks.gamma1_0PS : shaderChunks.gamma1_0PS;
+    }
+
+    static tonemapCode(value, chunks) {
+        if (!chunks) chunks = shaderChunks;
+        if (value === TONEMAP_FILMIC) {
+            return chunks.tonemappingFilmicPS ? chunks.tonemappingFilmicPS : shaderChunks.tonemappingFilmicPS;
+        } else if (value === TONEMAP_LINEAR) {
+            return chunks.tonemappingLinearPS ? chunks.tonemappingLinearPS : shaderChunks.tonemappingLinearPS;
+        } else if (value === TONEMAP_HEJL) {
+            return chunks.tonemappingHejlPS ? chunks.tonemappingHejlPS : shaderChunks.tonemappingHejlPS;
+        } else if (value === TONEMAP_ACES) {
+            return chunks.tonemappingAcesPS ? chunks.tonemappingAcesPS : shaderChunks.tonemappingAcesPS;
+        } else if (value === TONEMAP_ACES2) {
+            return chunks.tonemappingAces2PS ? chunks.tonemappingAces2PS : shaderChunks.tonemappingAces2PS;
+        }
+        return chunks.tonemapingNonePS ? chunks.tonemapingNonePS : shaderChunks.tonemappingNonePS;
+    }
+}
+
+export { ShaderGenerator };
diff --git a/src/scene/shader-lib/programs/skybox.js b/src/scene/shader-lib/programs/skybox.js
index 6870d5aac..18220e8c6 100644
--- a/src/scene/shader-lib/programs/skybox.js
+++ b/src/scene/shader-lib/programs/skybox.js
@@ -2,33 +2,33 @@ import { SEMANTIC_POSITION } from '../../../platform/graphics/constants.js';
 import { shaderChunks } from '../chunks/chunks.js';
 import { ChunkUtils } from '../chunk-utils.js';
 
-import { gammaCode, tonemapCode } from './common.js';
 import { ShaderUtils } from '../../../platform/graphics/shader-utils.js';
+import { ShaderGenerator } from './shader-generator.js';
 
-const skybox = {
-    generateKey: function (options) {
+class ShaderGeneratorSkybox extends ShaderGenerator {
+    generateKey(options) {
         return options.type === 'cubemap' ?
             `skybox-${options.type}-${options.encoding}-${options.useIntensity}-${options.gamma}-${options.toneMapping}-${options.fixSeams}-${options.mip}` :
             `skybox-${options.type}-${options.encoding}-${options.useIntensity}-${options.gamma}-${options.toneMapping}`;
-    },
+    }
 
-    createShaderDefinition: function (device, options) {
+    createShaderDefinition(device, options) {
         let fshader = '';
         if (options.type === 'cubemap') {
             const mip2size = [128, 64, /* 32 */ 16, 8, 4, 2];
             fshader += options.mip ? shaderChunks.fixCubemapSeamsStretchPS : shaderChunks.fixCubemapSeamsNonePS;
             fshader += options.useIntensity ? shaderChunks.envMultiplyPS : shaderChunks.envConstPS;
             fshader += shaderChunks.decodePS;
-            fshader += gammaCode(options.gamma);
-            fshader += tonemapCode(options.toneMapping);
+            fshader += ShaderGenerator.gammaCode(options.gamma);
+            fshader += ShaderGenerator.tonemapCode(options.toneMapping);
             fshader += shaderChunks.skyboxHDRPS
                 .replace(/\$DECODE/g, ChunkUtils.decodeFunc(options.encoding))
                 .replace(/\$FIXCONST/g, (1 - 1 / mip2size[options.mip]) + "");
         } else {
             fshader += options.useIntensity ? shaderChunks.envMultiplyPS : shaderChunks.envConstPS;
             fshader += shaderChunks.decodePS;
-            fshader += gammaCode(options.gamma);
-            fshader += tonemapCode(options.toneMapping);
+            fshader += ShaderGenerator.gammaCode(options.gamma);
+            fshader += ShaderGenerator.tonemapCode(options.toneMapping);
             fshader += shaderChunks.sphericalPS;
             fshader += shaderChunks.envAtlasPS;
             fshader += shaderChunks.skyboxEnvPS.replace(/\$DECODE/g, ChunkUtils.decodeFunc(options.encoding));
@@ -43,6 +43,8 @@ const skybox = {
             fragmentCode: fshader
         });
     }
-};
+}
+
+const skybox = new ShaderGeneratorSkybox();
 
 export { skybox };
diff --git a/src/scene/shader-lib/programs/standard.js b/src/scene/shader-lib/programs/standard.js
index 615aa0ea0..78cfa29de 100644
--- a/src/scene/shader-lib/programs/standard.js
+++ b/src/scene/shader-lib/programs/standard.js
@@ -12,6 +12,7 @@ import { ChunkBuilder } from '../chunk-builder.js';
 import { ChunkUtils } from '../chunk-utils.js';
 import { StandardMaterialOptions } from '../../materials/standard-material-options.js';
 import { LitOptionsUtils } from './lit-options-utils.js';
+import { ShaderGenerator } from './shader-generator.js';
 
 const _matTex2D = [];
 
@@ -21,13 +22,13 @@ const buildPropertiesList = (options) => {
         .sort();
 };
 
-const standard = {
+class ShaderGeneratorStandard extends ShaderGenerator {
     // Shared Standard Material option structures
-    optionsContext: new StandardMaterialOptions(),
-    optionsContextMin: new StandardMaterialOptions(),
+    optionsContext = new StandardMaterialOptions();
 
-    /** @type { Function } */
-    generateKey: function (options) {
+    optionsContextMin = new StandardMaterialOptions();
+
+    generateKey(options) {
         let props;
         if (options === this.optionsContextMin) {
             if (!this.propsMin) this.propsMin = buildPropertiesList(options);
@@ -44,7 +45,7 @@ const standard = {
             LitOptionsUtils.generateKey(options.litOptions);
 
         return key;
-    },
+    }
 
     // get the value to replace $UV with in Map Shader functions
 
@@ -57,7 +58,7 @@ const standard = {
      * @returns {string} The code used to replace "$UV" in the shader code.
      * @private
      */
-    _getUvSourceExpression: function (transformPropName, uVPropName, options) {
+    _getUvSourceExpression(transformPropName, uVPropName, options) {
         const transformId = options[transformPropName];
         const uvChannel = options[uVPropName];
         const isMainPass = options.litOptions.pass === SHADER_FORWARD || options.litOptions.pass === SHADER_FORWARDHDR;
@@ -82,19 +83,19 @@ const standard = {
         }
 
         return expression;
-    },
+    }
 
-    _addMapDef: function (name, enabled) {
+    _addMapDef(name, enabled) {
         return enabled ? `#define ${name}\n` : `#undef ${name}\n`;
-    },
+    }
 
-    _addMapDefs: function (float, color, vertex, map, invert) {
+    _addMapDefs(float, color, vertex, map, invert) {
         return this._addMapDef("MAPFLOAT", float) +
                this._addMapDef("MAPCOLOR", color) +
                this._addMapDef("MAPVERTEX", vertex) +
                this._addMapDef("MAPTEXTURE", map) +
                this._addMapDef("MAPINVERT", invert);
-    },
+    }
 
     /**
      * Add chunk for Map Types (used for all maps except Normal).
@@ -108,7 +109,7 @@ const standard = {
      * @returns {string} The shader code to support this map.
      * @private
      */
-    _addMap: function (propName, chunkName, options, chunks, mapping, encoding = null) {
+    _addMap(propName, chunkName, options, chunks, mapping, encoding = null) {
         const mapPropName = propName + "Map";
         const uVPropName = mapPropName + "Uv";
         const identifierPropName = mapPropName + "Identifier";
@@ -181,9 +182,9 @@ const standard = {
 
         subCode = this._addMapDefs(isFloatTint, isVecTint, vertexColorOption, textureOption, invertOption) + subCode;
         return subCode.replace(/\$/g, "");
-    },
+    }
 
-    _correctChannel: function (p, chan, _matTex2D) {
+    _correctChannel(p, chan, _matTex2D) {
         if (_matTex2D[p] > 0) {
             if (_matTex2D[p] < chan.length) {
                 return chan.substring(0, _matTex2D[p]);
@@ -196,7 +197,7 @@ const standard = {
             }
             return chan;
         }
-    },
+    }
 
     /**
      * @param {import('../../../platform/graphics/graphics-device.js').GraphicsDevice} device - The
@@ -205,7 +206,7 @@ const standard = {
      * @returns {object} Returns the created shader definition.
      * @ignore
      */
-    createShaderDefinition: function (device, options) {
+    createShaderDefinition(device, options) {
 
         const shaderPassInfo = ShaderPass.get(device).getByIndex(options.litOptions.pass);
         const isForwardPass = shaderPassInfo.isForward;
@@ -510,6 +511,8 @@ const standard = {
 
         return litShader.getDefinition();
     }
-};
+}
+
+const standard = new ShaderGeneratorStandard();
 
 export { _matTex2D, standard };
diff --git a/src/scene/shader-lib/utils.js b/src/scene/shader-lib/utils.js
index 40039311b..8ed0eedc0 100644
--- a/src/scene/shader-lib/utils.js
+++ b/src/scene/shader-lib/utils.js
@@ -3,6 +3,7 @@ import { ShaderUtils } from '../../platform/graphics/shader-utils.js';
 import { shaderChunks } from './chunks/chunks.js';
 import { getProgramLibrary } from './get-program-library.js';
 import { Debug } from '../../core/debug.js';
+import { ShaderGenerator } from './programs/shader-generator.js';
 
 /**
  * Create a shader from named shader chunks.
@@ -61,6 +62,22 @@ function createShaderFromCode(device, vsCode, fsCode, uniqueName, attributes, us
     return shader;
 }
 
+class ShaderGeneratorPassThrough extends ShaderGenerator {
+    constructor(key, shaderDefinition) {
+        super();
+        this.key = key;
+        this.shaderDefinition = shaderDefinition;
+    }
+
+    generateKey(options) {
+        return this.key;
+    }
+
+    createShaderDefinition(device, options) {
+        return this.shaderDefinition;
+    }
+}
+
 /**
  * Process shader using shader processing options, utilizing cache of the ProgramLibrary
  *
@@ -78,17 +95,11 @@ function processShader(shader, processingOptions) {
     // 'shader' generator for a material - simply return existing shader definition. Use generator and getProgram
     // to allow for shader processing to be cached
     const name = shaderDefinition.name ?? 'shader';
+
+    // unique name based of the shader id
     const key = `${name}-id-${shader.id}`;
-    const materialGenerator = {
-        generateKey: function (options) {
-            // unique name based of the shader id
-            return key;
-        },
-
-        createShaderDefinition: function (device, options) {
-            return shaderDefinition;
-        }
-    };
+
+    const materialGenerator = new ShaderGeneratorPassThrough(key, shaderDefinition);
 
     // temporarily register the program generator
     const libraryModuleName = 'shader';


Test patch:
diff --git a/test/core/math/mat3.test.mjs b/test/core/math/mat3.test.mjs
new file mode 100644
index 000000000..efaee0fea
--- /dev/null
+++ b/test/core/math/mat3.test.mjs
@@ -0,0 +1,161 @@
+import { Mat3 } from '../../../src/core/math/mat3.js';
+import { Mat4 } from '../../../src/core/math/mat4.js';
+import { Vec3 } from '../../../src/core/math/vec3.js';
+
+import { expect } from 'chai';
+
+const identity =   [1, 0, 0, 0, 1, 0, 0, 0, 1];
+const increasing = [1, 2, 3, 4, 5, 6, 7, 8, 9];
+const decreasing = [9, 8, 7, 6, 5, 4, 3, 2, 1];
+const increasingTransposed = [1, 4, 7, 2, 5, 8, 3, 6, 9];
+
+const increasingMat4 = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16];
+
+describe('Mat3', function () {
+
+    describe('#data', function () {
+        it('is a Float32Array of length 9', function () {
+            const m = new Mat3();
+            expect(m.data).to.be.an.instanceof(Float32Array);
+            expect(m.data).to.have.length(9);
+        });
+
+        it('is initialized to the identity matrix', function () {
+            const m = new Mat3();
+            expect(m.data).to.deep.equal(new Float32Array(identity));
+        });
+    });
+
+    describe('#equals', function () {
+
+        it('returns true for the same matrix', function () {
+            const m = new Mat3();
+            m.data.set(increasing);
+            const n = new Mat3();
+            n.data.set(increasing);
+            expect(m.equals(n)).to.be.true;
+        });
+
+        it('returns false for different matrices', function () {
+            const m = new Mat3();
+            m.data.set(increasing);
+            const n = new Mat3();
+            n.data.set(decreasing);
+            expect(m.equals(n)).to.be.false;
+        });
+
+    });
+
+    describe('#clone', function () {
+        it('clones correctly', function () {
+            const m = new Mat3();
+            m.data.set(increasing);
+            const n = m.clone();
+            expect(m.equals(n)).to.be.true;
+        });
+    });
+
+    describe('#copy', function () {
+        it('copies correctly', function () {
+            const m = new Mat3();
+            m.data.set(increasing);
+            const n = new Mat3();
+            n.copy(m);
+            expect(m.equals(n)).to.be.true;
+        });
+    });
+
+    describe('#set', function () {
+        it('sets the matrix correctly', function () {
+            const m = new Mat3();
+            m.set(increasing);
+            const n = new Mat3();
+            n.set(increasing);
+            expect(m.equals(n)).to.be.true;
+        });
+    });
+
+    describe('#isIdentity', function () {
+        it('is true for the identity matrix', function () {
+            const m = new Mat3();
+            expect(m.isIdentity()).to.be.true;
+        });
+
+        it('is false for a non-identity matrix', function () {
+            const m = new Mat3();
+            m.data.set([1, 2, 3, 4, 5, 6, 7, 8, 9]);
+            expect(m.isIdentity()).to.be.false;
+        });
+    });
+
+    describe('#setIdentity', function () {
+        it('sets the matrix to the identity matrix', function () {
+            const m = new Mat3();
+            m.set(increasing);
+            expect(m.isIdentity()).to.be.false;
+            m.setIdentity();
+            expect(m.isIdentity()).to.be.true;
+        });
+    });
+
+    describe('#transpose', function () {
+
+        it('transposes the identity matrix to the identity matrix', function () {
+            const m = new Mat3();
+            m.transpose();
+            expect(m.isIdentity()).to.be.true;
+        });
+
+        it('transposes a non-identity matrix correctly', function () {
+            const m = new Mat3();
+            m.set(increasing);
+            m.transpose();
+            console.log(JSON.stringify(m.data));
+            expect(m.data).to.deep.equal(new Float32Array(increasingTransposed));
+        });
+
+        it('transposes a non-identity matrix correctly given a source matrix', function () {
+            const m = new Mat3();
+            m.set(increasing);
+            const n = new Mat3();
+            n.transpose(m);
+            expect(n.data).to.deep.equal(new Float32Array(increasingTransposed));
+        });
+    });
+
+    describe('#setFromMat4', function () {
+        it('sets the matrix correctly', function () {
+            const m = new Mat4();
+            m.set(increasingMat4);
+
+            const n = new Mat3();
+            n.setFromMat4(m);
+
+            expect(n.data).to.deep.equal(new Float32Array([1, 2, 3, 5, 6, 7, 9, 10, 11]));
+        });
+    });
+
+    describe('#invertMat4', function () {
+        it('inverts the matrix correctly', function () {
+            const m = new Mat4();
+            m.set([2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 1]);
+
+            const n = new Mat3();
+            n.invertMat4(m);
+
+            expect(n.data).to.deep.equal(new Float32Array([0.5, 0, 0, 0, 0.5, 0, 0, 0, 0.5]));
+        });
+    });
+
+    describe('#transformVector', function () {
+        it('transforms a vector correctly', function () {
+            const m = new Mat4();
+            m.setScale(2, 2, 2);
+
+            const v = new Vec3(2, 2, 2);
+            const result = m.transformVector(v);
+
+            expect(result.equals(new Vec3(4, 4, 4))).to.be.true;
+        });
+    });
+});
diff --git a/test/core/math/mat4.test.mjs b/test/core/math/mat4.test.mjs
index 9411e9505..0f116ee3f 100644
--- a/test/core/math/mat4.test.mjs
+++ b/test/core/math/mat4.test.mjs
@@ -343,6 +343,17 @@ describe('Mat4', function () {
             expect(m.data).to.deep.equal(new Float32Array(result));
         });
 
+        it('inverts a translation matrix to the inverse translation matrix given a source matrix', function () {
+            const m = new Mat4();
+            m.set([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 10, 20, 30, 1]);
+
+            const n = new Mat4();
+            n.invert(m);
+
+            const result = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -10, -20, -30, 1];
+            expect(n.data).to.deep.equal(new Float32Array(result));
+        });
+
         it('returns this', function () {
             const m = new Mat4();
             expect(m.invert()).to.equal(m);
@@ -999,6 +1010,17 @@ describe('Mat4', function () {
             expect(m.data).to.deep.equal(new Float32Array(result));
         });
 
+        it('flips a matrix along its top-left to bottom-right diagonal given a source matrix', function () {
+            const m = new Mat4();
+            m.set([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]);
+
+            const n = new Mat4();
+            n.transpose(m);
+
+            const result = [1, 5, 9, 13, 2, 6, 10, 14, 3, 7, 11, 15, 4, 8, 12, 16];
+            expect(n.data).to.deep.equal(new Float32Array(result));
+        });
+
         it('returns this', function () {
             const m = new Mat4();
             expect(m.transpose()).to.equal(m);
diff --git a/test/core/math/vec3.test.mjs b/test/core/math/vec3.test.mjs
index eeb276f49..cc0fc3739 100644
--- a/test/core/math/vec3.test.mjs
+++ b/test/core/math/vec3.test.mjs
@@ -322,7 +322,7 @@ describe('Vec3', function () {
 
         it('calculates the length squared of a zero length vector', function () {
             const v = new Vec3();
-            expect(v.length()).to.equal(0);
+            expect(v.lengthSq()).to.equal(0);
         });
 
         it('calculates the length squared of a vector', function () {