Repository: playcanvas/engine
Pull request #5569 (2023-08-16 20:22:40)

WorldClusters allocation is now dynamic, removed from composition update
Refactoring to limit the amount of LayerComposition updates.

Before: When a light was added / removed from the layer, the expensive composition update would execute. Part of the update was to assign world clusters (textures storing clustered lights) to individual layers based on their lights.

Now: The world clusters assignment takes place at the start of the frame. The layers lazily update their light hash values and world clusters are shared between layers based on this hash.

The is still a cost of computing a hash for a layer on frames lights are added / removed on it - but this is reasonably cheap.
Note that this is needed specifically for WebGL1 path, on other platforms we could use bitflag to encode layers and reject lights from a single clusters structure at runtime. One day.

Note that the composition still updates when lights are added / removed, and further refactoring is needed, this PR only addresses the clustered part of it.


Patch:
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 0b716731c..c99e7ca02 100644
--- a/src/core/math/mat4.js
+++ b/src/core/math/mat4.js
@@ -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/framework/lightmapper/lightmapper.js b/src/framework/lightmapper/lightmapper.js
index d1bf0ae3a..393be53dd 100644
--- a/src/framework/lightmapper/lightmapper.js
+++ b/src/framework/lightmapper/lightmapper.js
@@ -918,7 +918,7 @@ class Lightmapper {
         this.setupScene();
 
         // update layer composition
-        comp._update(device, clusteredLightingEnabled);
+        comp._update();
 
         // compute bounding boxes for nodes
         this.computeNodesBounds(bakeNodes);
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 4d61c9fd8..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,10 +11,8 @@ import {
 } from '../constants.js';
 
 import { RenderAction } from './render-action.js';
-import { WorldClusters } from '../lighting/world-clusters.js';
 
 const tempSet = new Set();
-const tempClusterArray = [];
 
 /**
  * Layer Composition is a collection of {@link Layer} that is fed to {@link Scene#layers} to define
@@ -109,47 +106,14 @@ class LayerComposition extends EventHandler {
 
         // _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) {
 
@@ -173,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;
 
@@ -314,15 +278,6 @@ class LayerComposition extends EventHandler {
             this._renderActions.length = renderActionCount;
         }
 
-        // allocate light clusters 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();
         }
@@ -369,96 +324,6 @@ class LayerComposition extends EventHandler {
         this._splitLightsArray(this);
     }
 
-    // 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
     addRenderAction(renderActions, renderActionIndex, layer, layerIndex, cameraIndex, cameraFirstRenderAction, postProcessMarked) {
 
@@ -510,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;
@@ -576,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/render-action.js b/src/scene/composition/render-action.js
index cbb4995ed..831ada334 100644
--- a/src/scene/composition/render-action.js
+++ b/src/scene/composition/render-action.js
@@ -14,6 +14,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;
 
diff --git a/src/scene/graph-node.js b/src/scene/graph-node.js
index cc7d39481..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;
         }
 
diff --git a/src/scene/layer.js b/src/scene/layer.js
index f9de62fc8..00f717e22 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 {
@@ -108,6 +111,31 @@ 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();
+
     /**
      * Create a new Layer instance.
      *
@@ -349,25 +377,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.
          *
@@ -385,9 +394,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;
@@ -406,16 +420,6 @@ class Layer {
         this._lightCube = null;
     }
 
-    /**
-     * True if the layer contains omni or spot lights
-     *
-     * @type {boolean}
-     * @ignore
-     */
-    get hasClusteredLights() {
-        return this._clusteredLightsSet.size > 0;
-    }
-
     /**
      * Enable the layer. Disabled layers are skipped. Defaults to true.
      *
@@ -480,6 +484,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.
      *
@@ -665,6 +679,12 @@ class Layer {
         }
     }
 
+    markLightsDirty() {
+        this._dirtyLights = true;
+        this._lightHashDirty = true;
+        this._lightIdHashDirty = true;
+    }
+
     /**
      * Adds a light to this layer.
      *
@@ -679,8 +699,7 @@ class Layer {
             this._lightsSet.add(l);
 
             this._lights.push(l);
-            this._dirtyLights = true;
-            this._lightHashDirty = true;
+            this.markLightsDirty();
         }
 
         if (l.type !== LIGHTTYPE_DIRECTIONAL) {
@@ -701,8 +720,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) {
@@ -717,39 +735,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.
      *
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/renderer/forward-renderer.js b/src/scene/renderer/forward-renderer.js
index 2b0d5cfba..927b1a989 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;
diff --git a/src/scene/renderer/renderer.js b/src/scene/renderer/renderer.js
index 54dd7d3c3..ccb2dc771 100644
--- a/src/scene/renderer/renderer.js
+++ b/src/scene/renderer/renderer.js
@@ -34,6 +34,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 +76,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 +98,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);
 
@@ -1141,31 +1152,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
     }
 
@@ -1213,7 +1205,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/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


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);