Repository: playcanvas/engine
Pull request #5423 (2023-06-21 11:23:11)

Updated the way WebGPU submits command buffers for rendering
A system is implemented to minimize the number of relatively expensive command buffer (CB) submits:
- CBs are collected into an array and submitted only when needed
- care is taken to keep CB submission in the same order as other operations in the gpu queue
- mipmaps for render target textures are generated as part of the same command buffer, instead of a separate CB
- gpu markers were moved to be captured by respective CBs

TODO: This is not completely finished, and the system is not enabled yet, until further work is done in a separate PR. Currently (subject to change):
- when CB for a render pass is created, it's submitted immediately. This is required to make sure queue.writeBuffer for UBs are done in the correct order, which will be addressed separately.


Patch:
diff --git a/examples/src/examples/graphics/contact-hardening-shadows.tsx b/examples/src/examples/graphics/contact-hardening-shadows.tsx
index 481bbe21c..4c864b3a5 100644
--- a/examples/src/examples/graphics/contact-hardening-shadows.tsx
+++ b/examples/src/examples/graphics/contact-hardening-shadows.tsx
@@ -197,7 +197,7 @@ class ContactHardeningShadowsExample {
                         range: 150,
                         shadowResolution: 2048,
                         shadowDistance: 100,
-                        lightSize: data.get('script.area.size'),
+                        penumbraSize: data.get('script.area.size'),
                         shadowType: data.get('script.area.shadowType'),
                         intensity: data.get('script.area.intensity'),
                         falloffMode: pc.LIGHTFALLOFF_INVERSESQUARED,
@@ -233,7 +233,7 @@ class ContactHardeningShadowsExample {
                         color: new pc.Color(1, 1, 1),
                         castShadows: true,
                         numCascades: 1,
-                        lightSize: data.get('script.directional.size'),
+                        penumbraSize: data.get('script.directional.size'),
                         shadowType: data.get('script.directional.shadowType'),
                         intensity: data.get('script.directional.intensity'),
                         shadowBias: 0.5,
@@ -249,7 +249,7 @@ class ContactHardeningShadowsExample {
                         type: "omni",
                         color: new pc.Color(1, 0.25, 0.25),
                         range: 25,
-                        lightSize: data.get('script.point.size'),
+                        penumbraSize: data.get('script.point.size'),
                         shadowType: data.get('script.point.shadowType'),
                         intensity: data.get('script.point.intensity'),
                         castShadows: true,
@@ -308,7 +308,7 @@ class ContactHardeningShadowsExample {
                                 brightMaterial.update();
                                 break;
                             case 'script.area.size':
-                                areaLight.light.lightSize = value;
+                                areaLight.light.penumbraSize = value;
                                 break;
                             case 'script.area.shadowType':
                                 areaLight.light.shadowType = parseInt(value);
@@ -320,7 +320,7 @@ class ContactHardeningShadowsExample {
                                 directionalLight.light.intensity = value;
                                 break;
                             case 'script.directional.size':
-                                directionalLight.light.lightSize = value;
+                                directionalLight.light.penumbraSize = value;
                                 break;
                             case 'script.directional.shadowType':
                                 directionalLight.light.shadowType = parseInt(value);
@@ -332,7 +332,7 @@ class ContactHardeningShadowsExample {
                                 lightOmni.light.intensity = value;
                                 break;
                             case 'script.point.size':
-                                lightOmni.light.lightSize = value;
+                                lightOmni.light.penumbraSize = value;
                                 break;
                             case 'script.point.shadowType':
                                 lightOmni.light.shadowType = parseInt(value);
diff --git a/src/core/constants.js b/src/core/constants.js
index d48266af0..baec38490 100644
--- a/src/core/constants.js
+++ b/src/core/constants.js
@@ -124,3 +124,10 @@ export const TRACE_ID_ELEMENT = "Element";
  * @type {string}
  */
 export const TRACEID_TEXTURES = 'Textures';
+
+/**
+ * Logs the render queue commands.
+ *
+ * @type {string}
+ */
+export const TRACEID_RENDER_QUEUE = 'RenderQueue';
diff --git a/src/framework/anim/evaluator/anim-clip.js b/src/framework/anim/evaluator/anim-clip.js
index a58e7db80..5edba2d97 100644
--- a/src/framework/anim/evaluator/anim-clip.js
+++ b/src/framework/anim/evaluator/anim-clip.js
@@ -9,6 +9,12 @@ import { AnimSnapshot } from './anim-snapshot.js';
  * @ignore
  */
 class AnimClip {
+    static eventFrame = {
+        start: 0,
+        end: 0,
+        residual: 0
+    };
+
     /**
      * Create a new animation clip.
      *
@@ -65,7 +71,11 @@ class AnimClip {
     }
 
     set speed(speed) {
+        const signChanged = Math.sign(speed) !== Math.sign(this._speed);
         this._speed = speed;
+        if (signChanged) {
+            this.alignCursorToCurrentTime();
+        }
     }
 
     get speed() {
@@ -104,35 +114,100 @@ class AnimClip {
         return this._eventCursor;
     }
 
-    alignCursorToCurrentTime() {
-        this._eventCursor = 0;
-        // move the event cursor to the event that should fire after the current time
-        while (this._track.events[this._eventCursor] && this._track.events[this._eventCursor].time < this.time) {
-            this._eventCursor++;
+    get eventCursorEnd() {
+        return this.isReverse ? 0 : this._track.events.length - 1;
+    }
+
+    get nextEvent() {
+        return this._track.events[this._eventCursor];
+    }
+
+    get isReverse() {
+        return this._speed < 0;
+    }
+
+    nextEventAheadOfTime(time) {
+        if (!this.nextEvent) return false;
+        return this.isReverse ? this.nextEvent.time <= time : this.nextEvent.time >= time;
+    }
+
+    nextEventBehindTime(time) {
+        if (!this.nextEvent) return false;
+        if (time === this.track.duration) {
+            return this.isReverse ? this.nextEvent.time >= time : this.nextEvent.time <= time;
         }
+        return this.isReverse ? this.nextEvent.time > time : this.nextEvent.time < time;
     }
 
-    activeEventsForFrame(frameStartTime, frameEndTime) {
-        if (frameStartTime === 0) {
-            this.eventCursor = 0;
+    resetEventCursor() {
+        this._eventCursor = this.isReverse ? this._track.events.length - 1 : 0;
+    }
+
+    moveEventCursor() {
+        this._eventCursor += (this.isReverse ? -1 : 1);
+        if (this._eventCursor >= this.track.events.length) {
+            this._eventCursor = 0;
+        } else if (this._eventCursor < 0) {
+            this._eventCursor = this.track.events.length - 1;
         }
-        let clippedFrameDuration;
+    }
+
+    clipFrameTime(frameEndTime) {
+        const eventFrame = AnimClip.eventFrame;
+        eventFrame.start = 0;
+        eventFrame.end = frameEndTime;
+        eventFrame.residual = 0;
+
         // if this frame overlaps with the end of the track, we should clip off the end of the frame time then check that clipped time later
-        if (frameEndTime > this.track.duration) {
-            clippedFrameDuration = frameEndTime - this.track.duration;
-            frameEndTime = this.track.duration;
+        if (this.isReverse) {
+            if (frameEndTime < 0) {
+                eventFrame.start = this.track.duration;
+                eventFrame.end = 0;
+                eventFrame.residual = frameEndTime + this.track.duration;
+            }
+        } else {
+            if (frameEndTime > this.track.duration) {
+                eventFrame.start = 0;
+                eventFrame.end = this.track.duration;
+                eventFrame.residual = frameEndTime - this.track.duration;
+            }
         }
+    }
 
-        // check whether the next event occurs during the current frame. If the frame end time is at the end of the track then test that inclusively too
-        while (this.track.events[this.eventCursor] && this.track.events[this.eventCursor].time >= frameStartTime && (frameEndTime === this.track.duration ? this.track.events[this.eventCursor].time <= frameEndTime : this.track.events[this.eventCursor].time < frameEndTime)) {
-            const event = this.track.events[this.eventCursor];
-            this._eventHandler.fire(event.name, { track: this.track, ...event });
-            this.eventCursor++;
+    alignCursorToCurrentTime() {
+        this.resetEventCursor();
+        while (this.nextEventBehindTime(this._time) && this._eventCursor !== this.eventCursorEnd) {
+            this.moveEventCursor();
         }
+    }
 
-        // if we had to clip the current frame, then we should check the start of the track for events during that clipped duration
-        if (Number.isFinite(clippedFrameDuration)) {
-            this.activeEventsForFrame(0, clippedFrameDuration);
+    fireNextEvent() {
+        this._eventHandler.fire(this.nextEvent.name, { track: this.track, ...this.nextEvent });
+        this.moveEventCursor();
+    }
+
+    fireNextEventInFrame(frameStartTime, frameEndTime) {
+        if (this.nextEventAheadOfTime(frameStartTime) && this.nextEventBehindTime(frameEndTime)) {
+            this.fireNextEvent();
+            return true;
+        }
+        return false;
+    }
+
+    activeEventsForFrame(frameStartTime, frameEndTime) {
+        // get frame start and end times clipped to the track duration with the residual duration stored
+        const eventFrame = AnimClip.eventFrame;
+        this.clipFrameTime(frameEndTime);
+        // fire all events that should fire during this clipped frame
+        const initialCursor = this.eventCursor;
+        while (this.fireNextEventInFrame(frameStartTime, eventFrame.end)) {
+            if (initialCursor === this.eventCursor) {
+                break;
+            }
+        }
+        // recurse the process until the residual duration is 0
+        if (this.loop && Math.abs(eventFrame.residual) > 0) {
+            this.activeEventsForFrame(eventFrame.start, eventFrame.residual);
         }
     }
 
diff --git a/src/framework/app-base.js b/src/framework/app-base.js
index a4630536a..9cdddf62c 100644
--- a/src/framework/app-base.js
+++ b/src/framework/app-base.js
@@ -1192,6 +1192,10 @@ class AppBase extends EventHandler {
         this.graphicsDevice.frameStart();
     }
 
+    frameEnd() {
+        this.graphicsDevice.frameEnd();
+    }
+
     /**
      * Render the application's scene. More specifically, the scene's {@link LayerComposition} is
      * rendered. This function is called internally in the application's main loop and does not
@@ -2212,6 +2216,7 @@ const makeTick = function (_app) {
                 application.updateCanvasSize();
                 application.frameStart();
                 application.render();
+                application.frameEnd();
                 application.renderNextFrame = false;
 
                 Debug.trace(TRACEID_RENDER_FRAME_TIME, `-- RenderEnd ${now().toFixed(2)}ms`);
diff --git a/src/framework/components/light/component.js b/src/framework/components/light/component.js
index 52b1f9cff..143af18d5 100644
--- a/src/framework/components/light/component.js
+++ b/src/framework/components/light/component.js
@@ -89,9 +89,9 @@ const _lightPropsDefault = [];
  * angle is specified in degrees. Affects spot lights only. Defaults to 40.
  * @property {number} outerConeAngle The angle at which the spotlight cone has faded to nothing.
  * The angle is specified in degrees. Affects spot lights only. Defaults to 45.
- * @property {number} lightSize Size of the light used for contact hardening shadows. For area lights
- * acts as a size multiplier with the area light dimensions. For punctual and directional lights
- * acts as the actual size of the light. Defaults to 1.0.
+ * @property {number} penumbraSize Size of penumbra for contact hardening shadows. For area lights
+ * acts as a multiplier with the dimensions of the area light. For punctual and directional lights
+ * it's the area size of the light. Defaults to 1.0.
  * @property {number} falloffMode Controls the rate at which a light attenuates from its position.
  * Can be:
  *
@@ -337,12 +337,12 @@ class LightComponent extends Component {
         return this.light.shadowUpdateOverrides;
     }
 
-    set lightSize(value) {
-        this.light.lightSize = value;
+    set penumbraSize(value) {
+        this.light.penumbraSize = value;
     }
 
-    get lightSize() {
-        return this.light.lightSize;
+    get penumbraSize() {
+        return this.light.penumbraSize;
     }
 }
 
@@ -567,7 +567,7 @@ function _defineProps() {
         }
     });
 
-    _lightProps.push("lightSize");
+    _lightProps.push("penumbraSize");
     _lightPropsDefault.push(1);
 }
 
diff --git a/src/framework/components/model/component.js b/src/framework/components/model/component.js
index 0025f1c24..fb1c8633f 100644
--- a/src/framework/components/model/component.js
+++ b/src/framework/components/model/component.js
@@ -300,7 +300,7 @@ class ModelComponent extends Component {
             this._model._immutable = false;
 
             this.removeModelFromLayers();
-            this.entity.removeChild(this._model.getGraph());
+            this._model.getGraph().destroy();
             delete this._model._entity;
 
             if (this._clonedModel) {
diff --git a/src/framework/entity.js b/src/framework/entity.js
index 0a55f89ef..7855b535a 100644
--- a/src/framework/entity.js
+++ b/src/framework/entity.js
@@ -519,28 +519,7 @@ class Entity extends GraphNode {
             this.c[name].system.removeComponent(this);
         }
 
-        // Detach from parent
-        if (this._parent)
-            this._parent.removeChild(this);
-
-        // recursively destroy all children
-        const children = this._children;
-        while (children.length) {
-
-            // remove a child from the array and disconnect it from the parent
-            const child = children.pop();
-            child._parent = null;
-
-            if (child instanceof Entity) {
-                child.destroy();
-            }
-        }
-
-        // fire destroy event
-        this.fire('destroy', this);
-
-        // clear all events
-        this.off();
+        super.destroy();
 
         // remove from entity index
         if (this._guid) {
diff --git a/src/framework/scene-registry.js b/src/framework/scene-registry.js
index d8ef8a130..b0ecaf165 100644
--- a/src/framework/scene-registry.js
+++ b/src/framework/scene-registry.js
@@ -376,14 +376,11 @@ class SceneRegistry {
         const app = this._app;
 
         const onBeforeAddHierarchy = (sceneItem) => {
-            // Destroy/Remove all nodes on the app.root
-            const rootChildren = app.root.children;
-            while (rootChildren.length > 0) {
-                const child = rootChildren[0];
-                child.reparent(null);
-                child.destroy?.();
+            // Destroy all nodes on the app.root
+            const { children } = app.root;
+            while (children.length) {
+                children[0].destroy();
             }
-
             app.applySceneSettings(sceneItem.data.settings);
         };
 
diff --git a/src/platform/graphics/graphics-device.js b/src/platform/graphics/graphics-device.js
index 4095d7005..21e8f8c4b 100644
--- a/src/platform/graphics/graphics-device.js
+++ b/src/platform/graphics/graphics-device.js
@@ -696,6 +696,15 @@ class GraphicsDevice extends EventHandler {
             }
         });
     }
+
+    /**
+     * Function which executes at the end of the frame. This should not be called manually, as it is
+     * handled by the AppBase instance.
+     *
+     * @ignore
+     */
+    frameEnd() {
+    }
 }
 
 export { GraphicsDevice };
diff --git a/src/platform/graphics/webgl/webgl-graphics-device.js b/src/platform/graphics/webgl/webgl-graphics-device.js
index 51f015142..3799b407d 100644
--- a/src/platform/graphics/webgl/webgl-graphics-device.js
+++ b/src/platform/graphics/webgl/webgl-graphics-device.js
@@ -2078,6 +2078,10 @@ class WebglGraphicsDevice extends GraphicsDevice {
         }
     }
 
+    submit() {
+        this.gl.flush();
+    }
+
     /**
      * Reads a block of pixels from a specified rectangle of the current color framebuffer into an
      * ArrayBufferView object.
@@ -2117,7 +2121,7 @@ class WebglGraphicsDevice extends GraphicsDevice {
 
         const clientWaitAsync = (flags, interval_ms) => {
             const sync = gl.fenceSync(gl.SYNC_GPU_COMMANDS_COMPLETE, 0);
-            gl.flush();
+            this.submit();
 
             return new Promise((resolve, reject) => {
                 function test() {
diff --git a/src/platform/graphics/webgpu/webgpu-buffer.js b/src/platform/graphics/webgpu/webgpu-buffer.js
index 5abafcdc3..994888cc9 100644
--- a/src/platform/graphics/webgpu/webgpu-buffer.js
+++ b/src/platform/graphics/webgpu/webgpu-buffer.js
@@ -1,4 +1,5 @@
-import { DebugHelper } from '../../../core/debug.js';
+import { TRACEID_RENDER_QUEUE } from '../../../core/constants.js';
+import { Debug, DebugHelper } from '../../../core/debug.js';
 
 /**
  * A WebGPU implementation of the Buffer.
@@ -77,6 +78,7 @@ class WebgpuBuffer {
         data.set(srcData);
 
         // copy data to the gpu buffer
+        Debug.trace(TRACEID_RENDER_QUEUE, `writeBuffer: ${this.buffer.label}`);
         wgpu.queue.writeBuffer(this.buffer, 0, data, 0, data.length);
 
         // TODO: handle usage types:
diff --git a/src/platform/graphics/webgpu/webgpu-clear-renderer.js b/src/platform/graphics/webgpu/webgpu-clear-renderer.js
index 211bcacf0..9367c25a3 100644
--- a/src/platform/graphics/webgpu/webgpu-clear-renderer.js
+++ b/src/platform/graphics/webgpu/webgpu-clear-renderer.js
@@ -133,9 +133,7 @@ class WebgpuClearRenderer {
             device.setShader(this.shader);
 
             const bindGroup = this.bindGroup;
-            if (bindGroup.defaultUniformBuffer) {
-                bindGroup.defaultUniformBuffer.update();
-            }
+            bindGroup.defaultUniformBuffer.update();
             bindGroup.update();
             device.setBindGroup(BINDGROUP_MESH, bindGroup);
 
diff --git a/src/platform/graphics/webgpu/webgpu-graphics-device.js b/src/platform/graphics/webgpu/webgpu-graphics-device.js
index 12f0ebcb8..f15129c6b 100644
--- a/src/platform/graphics/webgpu/webgpu-graphics-device.js
+++ b/src/platform/graphics/webgpu/webgpu-graphics-device.js
@@ -1,3 +1,4 @@
+import { TRACEID_RENDER_QUEUE } from '../../../core/constants.js';
 import { Debug, DebugHelper } from '../../../core/debug.js';
 import { Vec2 } from '../../../core/math/vec2.js';
 
@@ -72,6 +73,14 @@ class WebgpuGraphicsDevice extends GraphicsDevice {
      */
     commandEncoder;
 
+    /**
+     * Command buffers scheduled for execution on the GPU.
+     *
+     * @type {GPUCommandBuffer[]}
+     * @private
+     */
+    commandBuffers = [];
+
     constructor(canvas, options = {}) {
         super(canvas, options);
         options = this.initOptions;
@@ -291,6 +300,9 @@ class WebgpuGraphicsDevice extends GraphicsDevice {
 
         super.frameStart();
 
+        // submit any commands collected before the frame rendering
+        this.submit();
+
         WebgpuDebug.memory(this);
         WebgpuDebug.validate(this);
 
@@ -324,6 +336,11 @@ class WebgpuGraphicsDevice extends GraphicsDevice {
         WebgpuDebug.end(this);
     }
 
+    frameEnd() {
+        super.frameEnd();
+        this.submit();
+    }
+
     createUniformBufferImpl(uniformBuffer) {
         return new WebgpuUniformBuffer(uniformBuffer);
     }
@@ -506,6 +523,9 @@ class WebgpuGraphicsDevice extends GraphicsDevice {
      */
     startPass(renderPass) {
 
+        WebgpuDebug.internal(this);
+        WebgpuDebug.validate(this);
+
         const rt = renderPass.renderTarget || this.frameBuffer;
         Debug.assert(rt);
 
@@ -514,8 +534,9 @@ class WebgpuGraphicsDevice extends GraphicsDevice {
         /** @type {WebgpuRenderTarget} */
         const wrt = rt.impl;
 
-        WebgpuDebug.internal(this);
-        WebgpuDebug.validate(this);
+        // create a new encoder for each pass
+        this.commandEncoder = this.wgpu.createCommandEncoder();
+        DebugHelper.setLabel(this.commandEncoder, `${renderPass.name}-Encoder`);
 
         // framebuffer is initialized at the start of the frame
         if (rt !== this.frameBuffer) {
@@ -525,10 +546,6 @@ class WebgpuGraphicsDevice extends GraphicsDevice {
         // set up clear / store / load settings
         wrt.setupForRenderPass(renderPass);
 
-        // create a new encoder for each pass to keep the GPU busy with commands
-        this.commandEncoder = this.wgpu.createCommandEncoder();
-        DebugHelper.setLabel(this.commandEncoder, renderPass.name);
-
         // clear cached encoder state
         this.pipeline = null;
 
@@ -557,27 +574,56 @@ class WebgpuGraphicsDevice extends GraphicsDevice {
      */
     endPass(renderPass) {
 
+        // end the render pass
         this.passEncoder.end();
         this.passEncoder = null;
-
-        this.wgpu.queue.submit([this.commandEncoder.finish()]);
-        this.commandEncoder = null;
-
-        WebgpuDebug.end(this, { renderPass });
-        WebgpuDebug.end(this, { renderPass });
+        this.insideRenderPass = false;
 
         // each render pass can use different number of bind groups
         this.bindGroupFormats.length = 0;
 
-        this.insideRenderPass = false;
-
-        // generate mipmaps
+        // generate mipmaps using the same command buffer encoder
         for (let i = 0; i < renderPass.colorArrayOps.length; i++) {
             const colorOps = renderPass.colorArrayOps[i];
             if (colorOps.mipmaps) {
                 this.mipmapRenderer.generate(renderPass.renderTarget._colorBuffers[i].impl);
             }
         }
+
+        // schedule command buffer submission
+        const cb = this.commandEncoder.finish();
+        DebugHelper.setLabel(cb, `${renderPass.name}-CommandBuffer`);
+
+        this.addCommandBuffer(cb);
+        this.commandEncoder = null;
+
+        WebgpuDebug.end(this, { renderPass });
+        WebgpuDebug.end(this, { renderPass });
+
+        // submit command buffer
+        // TODO: remove this when other parts of the codebase are ready, to avoid submitting multiple times
+        this.submit();
+    }
+
+    addCommandBuffer(commandBuffer) {
+        this.commandBuffers.push(commandBuffer);
+    }
+
+    submit() {
+        if (this.commandBuffers.length > 0) {
+
+            Debug.call(() => {
+                if (this.commandBuffers.length > 0) {
+                    Debug.trace(TRACEID_RENDER_QUEUE, `SUBMIT ${this.commandBuffers.length}`);
+                    for (let i = 0; i < this.commandBuffers.length; i++) {
+                        Debug.trace(TRACEID_RENDER_QUEUE, `  CB: ${this.commandBuffers[i].label}`);
+                    }
+                }
+            });
+
+            this.wgpu.queue.submit(this.commandBuffers);
+            this.commandBuffers.length = 0;
+        }
     }
 
     clear(options) {
@@ -656,10 +702,11 @@ class WebgpuGraphicsDevice extends GraphicsDevice {
             depthOrArrayLayers: 1
         };
 
-        DebugGraphics.pushGpuMarker(this, 'COPY-RT');
-
-        // use existing or create new encoder if between render passes
+        // use existing or create new encoder if not in a render pass
         const commandEncoder = this.commandEncoder ?? this.wgpu.createCommandEncoder();
+        DebugHelper.setLabel(commandEncoder, 'CopyRenderTarget-Encoder');
+
+        DebugGraphics.pushGpuMarker(this, 'COPY-RT');
 
         if (color) {
 
@@ -708,12 +755,19 @@ class WebgpuGraphicsDevice extends GraphicsDevice {
             commandEncoder.copyTextureToTexture(copySrc, copyDst, copySize);
         }
 
-        // submit the encoded commands if we created the encoder
+        DebugGraphics.popGpuMarker(this);
+
+        // if we created the encoder
         if (!this.commandEncoder) {
-            this.wgpu.queue.submit([commandEncoder.finish()]);
-        }
 
-        DebugGraphics.popGpuMarker(this);
+            // submit the scheduled commands first
+            this.submit();
+
+            // copy operation runs next
+            const cb = commandEncoder.finish();
+            DebugHelper.setLabel(cb, 'CopyRenderTarget-CommandBuffer');
+            this.addCommandBuffer(cb);
+        }
 
         return true;
     }
diff --git a/src/platform/graphics/webgpu/webgpu-mipmap-renderer.js b/src/platform/graphics/webgpu/webgpu-mipmap-renderer.js
index fb7c24b8c..925b0e54e 100644
--- a/src/platform/graphics/webgpu/webgpu-mipmap-renderer.js
+++ b/src/platform/graphics/webgpu/webgpu-mipmap-renderer.js
@@ -77,11 +77,6 @@ class WebgpuMipmapRenderer {
         }
 
         const device = this.device;
-        DebugGraphics.pushGpuMarker(device, 'MIPMAP-RENDERER');
-
-        // cannot run this inside render pass
-        Debug.assert(!device.insideRenderPass, 'Mipmap generation cannot be run inside a render pass.', webgpuTexture.texture);
-
         const wgpu = device.wgpu;
 
         /** @type {import('./webgpu-shader.js').WebgpuShader} */
@@ -119,7 +114,11 @@ class WebgpuMipmapRenderer {
         }
 
         // loop through each mip level and render the previous level's contents into it.
-        const commandEncoder = wgpu.createCommandEncoder();
+        const commandEncoder = device.commandEncoder ?? wgpu.createCommandEncoder();
+        DebugHelper.setLabel(commandEncoder, 'MipmapRendererEncoder');
+
+        DebugGraphics.pushGpuMarker(device, 'MIPMAP-RENDERER');
+
         for (let i = 1; i < textureDescr.mipLevelCount; i++) {
 
             for (let face = 0; face < numFaces; face++) {
@@ -161,12 +160,18 @@ class WebgpuMipmapRenderer {
             }
         }
 
-        wgpu.queue.submit([commandEncoder.finish()]);
+        DebugGraphics.popGpuMarker(device);
+
+        // submit the encoded commands if we created the encoder
+        if (!device.commandEncoder) {
+
+            const cb = commandEncoder.finish();
+            DebugHelper.setLabel(cb, 'MipmapRenderer-CommandBuffer');
+            device.addCommandBuffer(cb);
+        }
 
         // clear invalidated state
         device.pipeline = null;
-
-        DebugGraphics.popGpuMarker(device);
     }
 }
 
diff --git a/src/platform/graphics/webgpu/webgpu-texture.js b/src/platform/graphics/webgpu/webgpu-texture.js
index 7202dfde6..bdcaf8020 100644
--- a/src/platform/graphics/webgpu/webgpu-texture.js
+++ b/src/platform/graphics/webgpu/webgpu-texture.js
@@ -1,3 +1,4 @@
+import { TRACEID_RENDER_QUEUE } from '../../../core/constants.js';
 import { Debug, DebugHelper } from '../../../core/debug.js';
 import { math } from '../../../core/math/math.js';
 
@@ -314,7 +315,6 @@ class WebgpuTexture {
 
         const texture = this.texture;
         if (texture._levels) {
-            const wgpu = device.wgpu;
 
             // upload texture data if any
             let anyUploads = false;
@@ -337,7 +337,7 @@ class WebgpuTexture {
 
                                 } else if (ArrayBuffer.isView(faceSource)) { // typed array
 
-                                    this.uploadTypedArrayData(wgpu, faceSource, mipLevel, face);
+                                    this.uploadTypedArrayData(device, faceSource, mipLevel, face);
                                     anyUploads = true;
 
                                 } else {
@@ -360,7 +360,7 @@ class WebgpuTexture {
 
                         } else if (ArrayBuffer.isView(mipObject)) { // typed array
 
-                            this.uploadTypedArrayData(wgpu, mipObject, mipLevel, 0);
+                            this.uploadTypedArrayData(device, mipObject, mipLevel, 0);
                             anyUploads = true;
 
                         } else {
@@ -408,12 +408,17 @@ class WebgpuTexture {
             depthOrArrayLayers: 1   // single layer
         };
 
+        // submit existing commands before copying to preserve order
+        device.submit();
+
+        Debug.trace(TRACEID_RENDER_QUEUE, `CopyImage2Tex: mip:${mipLevel} face:${face} ${this.texture.name}`);
         device.wgpu.queue.copyExternalImageToTexture(src, dst, copySize);
     }
 
-    uploadTypedArrayData(wgpu, data, mipLevel, face) {
+    uploadTypedArrayData(device, data, mipLevel, face) {
 
         const texture = this.texture;
+        const wgpu = device.wgpu;
 
         /** @type {GPUImageCopyTexture} */
         const dest = {
@@ -452,6 +457,10 @@ class WebgpuTexture {
             depthOrArrayLayers: 1
         };
 
+        // submit existing commands before copying to preserve order
+        device.submit();
+
+        Debug.trace(TRACEID_RENDER_QUEUE, `writeTex: mip:${mipLevel} face:${face} ${this.texture.name}`);
         wgpu.queue.writeTexture(dest, data, dataLayout, size);
     }
 }
diff --git a/src/scene/graph-node.js b/src/scene/graph-node.js
index 47aa3dad0..cd32bfdcc 100644
--- a/src/scene/graph-node.js
+++ b/src/scene/graph-node.js
@@ -480,6 +480,37 @@ class GraphNode extends EventHandler {
         return this;
     }
 
+
+    /**
+     * Detach a GraphNode from the hierarchy and recursively destroy all children.
+     *
+     * @example
+     * const firstChild = this.entity.children[0];
+     * firstChild.destroy(); // delete child, all components and remove from hierarchy
+     */
+    destroy() {
+        // Detach from parent
+        this._parent?.removeChild(this);
+
+        // Recursively destroy all children
+        const children = this._children;
+        while (children.length) {
+            // Remove last child from the array
+            const child = children.pop();
+            // Disconnect it from the parent: this is only an optimization step, to prevent calling
+            // GraphNode#removeChild which would try to refind it via this._children.indexOf (which
+            // will fail, because we just removed it).
+            child._parent = null;
+            child.destroy();
+        }
+
+        // fire destroy event
+        this.fire('destroy', this);
+
+        // clear all events
+        this.off();
+    }
+
     /**
      * Search the graph node and all of its descendants for the nodes that satisfy some search
      * criteria.
diff --git a/src/scene/graphics/quad-render-utils.js b/src/scene/graphics/quad-render-utils.js
index afbbd753f..1da5e302f 100644
--- a/src/scene/graphics/quad-render-utils.js
+++ b/src/scene/graphics/quad-render-utils.js
@@ -35,8 +35,6 @@ function drawQuadWithShader(device, target, shader, rect, scissorRect) {
         }
     });
 
-    DebugGraphics.pushGpuMarker(device, "drawQuadWithShader");
-
     device.setCullMode(CULLFACE_NONE);
     device.setDepthState(DepthState.NODEPTH);
     device.setStencilState(null, null);
@@ -55,7 +53,9 @@ function drawQuadWithShader(device, target, shader, rect, scissorRect) {
 
     // prepare a render pass to render the quad to the render target
     const renderPass = new RenderPass(device, () => {
+        DebugGraphics.pushGpuMarker(device, "drawQuadWithShader");
         quad.render(rect, scissorRect);
+        DebugGraphics.popGpuMarker(device);
     });
     DebugHelper.setName(renderPass, `RenderPass-drawQuadWithShader${target ? `-${target.name}` : 'Framebuffer'}`);
     renderPass.init(target);
@@ -74,9 +74,12 @@ function drawQuadWithShader(device, target, shader, rect, scissorRect) {
     }
 
     renderPass.render();
-    quad.destroy();
 
-    DebugGraphics.popGpuMarker(device);
+    // TODO: remove this submission, when UB allocates from per frame buffers. For now, we need to submit the command buffer,
+    // as we're destroying the UB buffer on the next line, and the command buffer needs to be submitted before that.
+    device.submit();
+
+    quad.destroy();
 }
 
 /**
diff --git a/src/scene/light.js b/src/scene/light.js
index 3af5247be..6e79d0559 100644
--- a/src/scene/light.js
+++ b/src/scene/light.js
@@ -187,7 +187,7 @@ class Light {
         this._normalOffsetBias = 0.0;
         this.shadowUpdateMode = SHADOWUPDATE_REALTIME;
         this.shadowUpdateOverrides = null;
-        this._lightSize = 1.0;
+        this._penumbraSize = 1.0;
         this._isVsm = false;
         this._isPcf = true;
 
@@ -457,12 +457,12 @@ class Light {
         return this._outerConeAngle;
     }
 
-    set lightSize(value) {
-        this._lightSize = value;
+    set penumbraSize(value) {
+        this._penumbraSize = value;
     }
 
-    get lightSize() {
-        return this._lightSize;
+    get penumbraSize() {
+        return this._penumbraSize;
     }
 
     _updateOuterAngle(angle) {
@@ -675,7 +675,7 @@ class Light {
         clone.vsmBlurSize = this._vsmBlurSize;
         clone.vsmBlurMode = this.vsmBlurMode;
         clone.vsmBias = this.vsmBias;
-        clone.lightSize = this.lightSize;
+        clone.penumbraSize = this.penumbraSize;
         clone.shadowUpdateMode = this.shadowUpdateMode;
         clone.mask = this.mask;
 
diff --git a/src/scene/renderer/forward-renderer.js b/src/scene/renderer/forward-renderer.js
index 0527d12a7..8b8a77394 100644
--- a/src/scene/renderer/forward-renderer.js
+++ b/src/scene/renderer/forward-renderer.js
@@ -111,7 +111,7 @@ class ForwardRenderer extends Renderer {
         this.lightCookieIntId = [];
         this.lightCookieMatrixId = [];
         this.lightCookieOffsetId = [];
-        this.lightSizeId = [];
+        this.lightShadowSearchAreaId = [];
         this.lightCameraParamsId = [];
 
         // shadow cascades
@@ -174,6 +174,7 @@ class ForwardRenderer extends Renderer {
         this.lightShadowMatrixId[i] = scope.resolve(light + '_shadowMatrix');
         this.lightShadowParamsId[i] = scope.resolve(light + '_shadowParams');
         this.lightShadowIntensity[i] = scope.resolve(light + '_shadowIntensity');
+        this.lightShadowSearchAreaId[i] = scope.resolve(light + '_shadowSearchArea');
         this.lightRadiusId[i] = scope.resolve(light + '_radius');
         this.lightPos[i] = new Float32Array(3);
         this.lightPosId[i] = scope.resolve(light + '_position');
@@ -187,7 +188,6 @@ class ForwardRenderer extends Renderer {
         this.lightCookieIntId[i] = scope.resolve(light + '_cookieIntensity');
         this.lightCookieMatrixId[i] = scope.resolve(light + '_cookieMatrix');
         this.lightCookieOffsetId[i] = scope.resolve(light + '_cookieOffset');
-        this.lightSizeId[i] = scope.resolve(light + '_size');
         this.lightCameraParamsId[i] = scope.resolve(light + '_cameraParams');
 
         // shadow cascades
@@ -257,11 +257,13 @@ class ForwardRenderer extends Renderer {
                 this.shadowCascadeDistancesId[cnt].setValue(directional._shadowCascadeDistances);
                 this.shadowCascadeCountId[cnt].setValue(directional.numCascades);
                 this.lightShadowIntensity[cnt].setValue(directional.shadowIntensity);
-                this.lightSizeId[cnt].setValue(directional.lightSize);
+
+                const pixelsPerMeter = 1.0 / (lightRenderData.shadowCamera.renderTarget.width / directional.penumbraSize);
+                this.lightShadowSearchAreaId[cnt].setValue(pixelsPerMeter);
 
                 const cameraParams = directional._shadowCameraParams;
                 cameraParams.length = 4;
-                cameraParams[0] = 2.0;
+                cameraParams[0] = 0;
                 cameraParams[1] = lightRenderData.shadowCamera._farClip;
                 cameraParams[2] = lightRenderData.shadowCamera._nearClip;
                 cameraParams[3] = 1;
@@ -329,12 +331,13 @@ class ForwardRenderer extends Renderer {
             params[3] = 1.0 / omni.attenuationEnd;
             this.lightShadowParamsId[cnt].setValue(params);
             this.lightShadowIntensity[cnt].setValue(omni.shadowIntensity);
-            this.lightSizeId[cnt].setValue(omni.lightSize);
 
-            const fov = lightRenderData.shadowCamera._fov * Math.PI / 180.0;
+            const pixelsPerMeter = 1.0 / (lightRenderData.shadowCamera.renderTarget.width / omni.penumbraSize);
+            this.lightShadowSearchAreaId[cnt].setValue(pixelsPerMeter);
             const cameraParams = omni._shadowCameraParams;
+
             cameraParams.length = 4;
-            cameraParams[0] = 1.0 / Math.tan(fov / 2.0);
+            cameraParams[0] = 0;
             cameraParams[1] = lightRenderData.shadowCamera._farClip;
             cameraParams[2] = lightRenderData.shadowCamera._nearClip;
             cameraParams[3] = 0;
@@ -394,12 +397,15 @@ class ForwardRenderer extends Renderer {
             params[3] = 1.0 / spot.attenuationEnd;
             this.lightShadowParamsId[cnt].setValue(params);
             this.lightShadowIntensity[cnt].setValue(spot.shadowIntensity);
-            this.lightSizeId[cnt].setValue(spot.lightSize);
 
+            const pixelsPerMeter = 1.0 / (lightRenderData.shadowCamera.renderTarget.width / spot.penumbraSize);
             const fov = lightRenderData.shadowCamera._fov * Math.PI / 180.0;
+            const fovRatio = 1.0 / Math.tan(fov / 2.0);
+            this.lightShadowSearchAreaId[cnt].setValue(pixelsPerMeter * fovRatio);
+
             const cameraParams = spot._shadowCameraParams;
             cameraParams.length = 4;
-            cameraParams[0] = 1.0 / Math.tan(fov / 2.0);
+            cameraParams[0] = 0;
             cameraParams[1] = lightRenderData.shadowCamera._farClip;
             cameraParams[2] = lightRenderData.shadowCamera._nearClip;
             cameraParams[3] = 0;
diff --git a/src/scene/shader-lib/chunks/lit/frag/clusteredLight.js b/src/scene/shader-lib/chunks/lit/frag/clusteredLight.js
index f2a466264..597af12d4 100644
--- a/src/scene/shader-lib/chunks/lit/frag/clusteredLight.js
+++ b/src/scene/shader-lib/chunks/lit/frag/clusteredLight.js
@@ -464,14 +464,14 @@ void evaluateLight(
                     } else {
 
                         // omni shadow
-                        normalOffsetPointShadow(shadowParams, dLightPosW, dLightDirW, dLightDirNormW, geometricNormal);  // normalBias adjusted for distance
+                        vec3 dir = normalOffsetPointShadow(shadowParams, dLightPosW, dLightDirW, dLightDirNormW, geometricNormal);  // normalBias adjusted for distance
 
                         #if defined(CLUSTER_SHADOW_TYPE_PCF1)
-                            float shadow = getShadowOmniClusteredPCF1(SHADOWMAP_PASS(shadowAtlasTexture), shadowParams, light.omniAtlasViewport, shadowEdgePixels, dLightDirW);
+                            float shadow = getShadowOmniClusteredPCF1(SHADOWMAP_PASS(shadowAtlasTexture), shadowParams, light.omniAtlasViewport, shadowEdgePixels, dir);
                         #elif defined(CLUSTER_SHADOW_TYPE_PCF3)
-                            float shadow = getShadowOmniClusteredPCF3(SHADOWMAP_PASS(shadowAtlasTexture), shadowParams, light.omniAtlasViewport, shadowEdgePixels, dLightDirW);
+                            float shadow = getShadowOmniClusteredPCF3(SHADOWMAP_PASS(shadowAtlasTexture), shadowParams, light.omniAtlasViewport, shadowEdgePixels, dir);
                         #elif defined(CLUSTER_SHADOW_TYPE_PCF5)
-                            float shadow = getShadowOmniClusteredPCF5(SHADOWMAP_PASS(shadowAtlasTexture), shadowParams, light.omniAtlasViewport, shadowEdgePixels, dLightDirW);
+                            float shadow = getShadowOmniClusteredPCF5(SHADOWMAP_PASS(shadowAtlasTexture), shadowParams, light.omniAtlasViewport, shadowEdgePixels, dir);
                         #endif
                         falloffAttenuation *= mix(1.0, shadow, light.shadowIntensity);
                     }
diff --git a/src/scene/shader-lib/chunks/lit/frag/clusteredLightShadows.js b/src/scene/shader-lib/chunks/lit/frag/clusteredLightShadows.js
index b2ba651a3..f8810e3b9 100644
--- a/src/scene/shader-lib/chunks/lit/frag/clusteredLightShadows.js
+++ b/src/scene/shader-lib/chunks/lit/frag/clusteredLightShadows.js
@@ -14,11 +14,11 @@ void getShadowCoordPerspZbufferNormalOffset(mat4 shadowMatrix, vec4 shadowParams
     _getShadowCoordPerspZbuffer(shadowMatrix, shadowParams, wPos);
 }
 
-void normalOffsetPointShadow(vec4 shadowParams, vec3 lightPos, inout vec3 lightDir, vec3 lightDirNorm, vec3 normal) {
+vec3 normalOffsetPointShadow(vec4 shadowParams, vec3 lightPos, inout vec3 lightDir, vec3 lightDirNorm, vec3 normal) {
     float distScale = length(lightDir);
     vec3 wPos = vPositionW + normal * shadowParams.y * clamp(1.0 - dot(normal, -lightDirNorm), 0.0, 1.0) * distScale; //0.02
     vec3 dir = wPos - lightPos;
-    lightDir = dir;
+    return dir;
 }
 
 #ifdef GL2
diff --git a/src/scene/shader-lib/chunks/lit/frag/shadowPCSS.js b/src/scene/shader-lib/chunks/lit/frag/shadowPCSS.js
index 1e38c87d9..336ac068e 100644
--- a/src/scene/shader-lib/chunks/lit/frag/shadowPCSS.js
+++ b/src/scene/shader-lib/chunks/lit/frag/shadowPCSS.js
@@ -73,7 +73,7 @@ float PCSSBlockerDistance(TEXTURE_ACCEPT(shadowMap), vec2 sampleCoords[PCSS_SAMP
     return -1.0;
 }
 
-float PCSS(TEXTURE_ACCEPT(shadowMap), vec3 shadowCoords, vec4 cameraParams, float oneOverShadowMapSize, vec2 lightSize) {
+float PCSS(TEXTURE_ACCEPT(shadowMap), vec3 shadowCoords, vec4 cameraParams, vec2 shadowSearchArea) {
     float receiverDepth = linearizeDepth(shadowCoords.z, cameraParams);
 #ifndef GL2
     // If using packed depth on GL1, we need to normalize to get the correct receiver depth
@@ -88,13 +88,12 @@ float PCSS(TEXTURE_ACCEPT(shadowMap), vec3 shadowCoords, vec4 cameraParams, floa
     }
 
     // Calculate the ratio of FOV between 45.0 degrees (tan(45) == 1) and the FOV of the camera    
-    float fovRatioAtDepth = cameraParams.x;
-    float averageBlocker = PCSSBlockerDistance(TEXTURE_PASS(shadowMap), samplePoints, shadowCoords.xy, oneOverShadowMapSize * lightSize * fovRatioAtDepth, receiverDepth);
+    float averageBlocker = PCSSBlockerDistance(TEXTURE_PASS(shadowMap), samplePoints, shadowCoords.xy, shadowSearchArea, receiverDepth);
     if (averageBlocker == -1.0) {
         return 1.0;
     } else {
 
-        vec2 filterRadius = ((receiverDepth - averageBlocker) / averageBlocker) * lightSize * oneOverShadowMapSize * fovRatioAtDepth;
+        vec2 filterRadius = ((receiverDepth - averageBlocker) / averageBlocker) * shadowSearchArea;
 
         float shadow = 0.0;
 
@@ -114,11 +113,11 @@ float PCSS(TEXTURE_ACCEPT(shadowMap), vec3 shadowCoords, vec4 cameraParams, floa
     } 
 }
 
-float PCSSCubeBlockerDistance(samplerCube shadowMap, vec3 lightDirNorm, vec3 samplePoints[PCSS_SAMPLE_COUNT], float z, float lightSize) {
+float PCSSCubeBlockerDistance(samplerCube shadowMap, vec3 lightDirNorm, vec3 samplePoints[PCSS_SAMPLE_COUNT], float z, float shadowSearchArea) {
     float blockers = 0.0;
     float averageBlocker = 0.0;
     for (int i = 0; i < PCSS_SAMPLE_COUNT; i++) {
-        vec3 sampleDir = lightDirNorm + samplePoints[i] * lightSize;
+        vec3 sampleDir = lightDirNorm + samplePoints[i] * shadowSearchArea;
         sampleDir = normalize(sampleDir);
 
     #ifdef GL2
@@ -136,7 +135,7 @@ float PCSSCubeBlockerDistance(samplerCube shadowMap, vec3 lightDirNorm, vec3 sam
     return -1.0;
 }
 
-float PCSSCube(samplerCube shadowMap, vec4 shadowParams, vec3 shadowCoords, vec4 cameraParams, float oneOverShadowMapSize, float lightSize, vec3 lightDir) {
+float PCSSCube(samplerCube shadowMap, vec4 shadowParams, vec3 shadowCoords, vec4 cameraParams, float shadowSearchArea, vec3 lightDir) {
     
     vec3 samplePoints[PCSS_SAMPLE_COUNT];
     float noise = gradientNoise( gl_FragCoord.xy ) * 2.0 * PI;
@@ -148,12 +147,12 @@ float PCSSCube(samplerCube shadowMap, vec4 shadowParams, vec3 shadowCoords, vec4
     float receiverDepth = length(lightDir) * shadowParams.w + shadowParams.z;
     vec3 lightDirNorm = normalize(lightDir);
     
-    float averageBlocker = PCSSCubeBlockerDistance(shadowMap, lightDirNorm, samplePoints, receiverDepth, lightSize * oneOverShadowMapSize * 2.0);
+    float averageBlocker = PCSSCubeBlockerDistance(shadowMap, lightDirNorm, samplePoints, receiverDepth, shadowSearchArea);
     if (averageBlocker == -1.0) {
         return 1.0;
     } else {
 
-        float filterRadius = ((receiverDepth - averageBlocker) / averageBlocker) * lightSize * oneOverShadowMapSize * 2.0;
+        float filterRadius = ((receiverDepth - averageBlocker) / averageBlocker) * shadowSearchArea;
 
         float shadow = 0.0;
         for (int i = 0; i < PCSS_SAMPLE_COUNT; i++)
@@ -173,16 +172,16 @@ float PCSSCube(samplerCube shadowMap, vec4 shadowParams, vec3 shadowCoords, vec4
     }
 }
 
-float getShadowPointPCSS(samplerCube shadowMap, vec3 shadowCoord, vec4 shadowParams, vec4 cameraParams, vec2 lightSize, vec3 lightDir) {
-    return PCSSCube(shadowMap, shadowParams, shadowCoord, cameraParams, (1.0 / shadowParams.x), lightSize.x, lightDir);
+float getShadowPointPCSS(samplerCube shadowMap, vec3 shadowCoord, vec4 shadowParams, vec4 cameraParams, vec2 shadowSearchArea, vec3 lightDir) {
+    return PCSSCube(shadowMap, shadowParams, shadowCoord, cameraParams, shadowSearchArea.x, lightDir);
 }
 
-float getShadowSpotPCSS(TEXTURE_ACCEPT(shadowMap), vec3 shadowCoord, vec4 shadowParams, vec4 cameraParams, vec2 lightSize, vec3 lightDir) {
-    return PCSS(TEXTURE_PASS(shadowMap), shadowCoord, cameraParams, (1.0 / shadowParams.x), lightSize);
+float getShadowSpotPCSS(TEXTURE_ACCEPT(shadowMap), vec3 shadowCoord, vec4 shadowParams, vec4 cameraParams, vec2 shadowSearchArea, vec3 lightDir) {
+    return PCSS(TEXTURE_PASS(shadowMap), shadowCoord, cameraParams, shadowSearchArea);
 }
 
-float getShadowPCSS(TEXTURE_ACCEPT(shadowMap), vec3 shadowCoord, vec4 shadowParams, vec4 cameraParams, vec2 lightSize, vec3 lightDir) {
-    return PCSS(TEXTURE_PASS(shadowMap), shadowCoord, cameraParams, (1.0 / shadowParams.x), lightSize);
+float getShadowPCSS(TEXTURE_ACCEPT(shadowMap), vec3 shadowCoord, vec4 shadowParams, vec4 cameraParams, vec2 shadowSearchArea, vec3 lightDir) {
+    return PCSS(TEXTURE_PASS(shadowMap), shadowCoord, cameraParams, shadowSearchArea);
 }
 
 `;
diff --git a/src/scene/shader-lib/chunks/standard/frag/litShaderArgs.js b/src/scene/shader-lib/chunks/standard/frag/litShaderArgs.js
index 43d4f97e2..c12429686 100644
--- a/src/scene/shader-lib/chunks/standard/frag/litShaderArgs.js
+++ b/src/scene/shader-lib/chunks/standard/frag/litShaderArgs.js
@@ -11,71 +11,71 @@ struct IridescenceArgs
 
 struct ClearcoatArgs
 {
+    // The normal used for the clearcoat layer
+    vec3 worldNormal;
+
     // Intensity of the clearcoat layer, range [0..1]
     float specularity;
 
     // Glossiness of clearcoat layer, range [0..1]
     float gloss;
-
-    // The normal used for the clearcoat layer
-    vec3 worldNormal;
 };
 
 struct SheenArgs
 {
-    // Glossiness of the sheen layer, range [0..1]
-    float gloss;
-
     // The color of the f0 specularity factor for the sheen layer
     vec3 specularity;
+
+    // Glossiness of the sheen layer, range [0..1]
+    float gloss;
 };
 
 struct LitShaderArguments {
-    // Transparency
-    float opacity;
-
     // Normal direction in world space
     vec3 worldNormal;
 
+    // Transparency
+    float opacity;
+
     // Surface albedo absorbance
     vec3 albedo;
 
     // Transmission factor (refraction), range [0..1]
     float transmission;
 
-    // Uniform thickness of medium, used by transmission, range [0..inf]
-    float thickness;
-
     // The f0 specularity factor
     vec3 specularity;
 
-    // The microfacet glossiness factor, range [0..1]
-    float gloss;
-
-    // Surface metalness factor, range [0..1]
-    float metalness;
+    // Uniform thickness of medium, used by transmission, range [0..inf]
+    float thickness;
 
-    // Specularity intensity factor, range [0..1]
-    float specularityFactor;
+    // Emission color
+    vec3 emission;
 
     // Ambient occlusion amount, range [0..1]
     float ao;
 
-    // Emission color
-    vec3 emission;
-
     // Light map color
     vec3 lightmap;
 
+    // Specularity intensity factor, range [0..1]
+    float specularityFactor;
+
     // Light map direction
     vec3 lightmapDir;
 
+    // The microfacet glossiness factor, range [0..1]
+    float gloss;
+
     // Iridescence extension arguments
     IridescenceArgs iridescence;
 
     // Clearcoat extension arguments
     ClearcoatArgs clearcoat;
 
+    // Surface metalness factor, range [0..1]
+    float metalness;
+
     // Sheen extension arguments
     SheenArgs sheen;
 };
diff --git a/src/scene/shader-lib/programs/lit-shader.js b/src/scene/shader-lib/programs/lit-shader.js
index fd79424d1..5e838ee69 100644
--- a/src/scene/shader-lib/programs/lit-shader.js
+++ b/src/scene/shader-lib/programs/lit-shader.js
@@ -543,7 +543,7 @@ class LitShader {
             code += "    gl_FragColor = vec4(1.0);\n"; // just the simplest code, color is not written anyway
 
             // clustered omni light is using shadow sampler and needs to write custom depth
-            if (shadowType === SHADOW_PCSS || (lightType === LIGHTTYPE_OMNI && !options.clusteeredLightingEnabled)) {
+            if (shadowType === SHADOW_PCSS || (lightType === LIGHTTYPE_OMNI && !options.clusteredLightingEnabled)) {
                 code += "   gl_FragColor.r = depth;\n";
             } else if (options.clusteredLightingEnabled && lightType === LIGHTTYPE_OMNI && device.supportsDepthShadow) {
                 code += "    gl_FragDepth = depth;\n";
@@ -646,7 +646,7 @@ class LitShader {
             decl.append("uniform vec3 light" + i + "_color;");
 
             if (light._shadowType === SHADOW_PCSS && light.castShadows && !options.noShadow) {
-                decl.append(`uniform float light${i}_size;`);
+                decl.append(`uniform float light${i}_shadowSearchArea;`);
                 decl.append(`uniform vec4 light${i}_cameraParams;`);
             }
 
@@ -1298,11 +1298,11 @@ class LitShader {
                             // VSM
                             shadowCoordArgs = `${shadowCoordArgs}, ${evsmExp}, dLightDirW`;
                         } else if (pcssShadows) {
-                            let lightSizeArg = `vec2(light${i}_size)`;
+                            let penumbraSizeArg = `vec2(light${i}_shadowSearchArea)`;
                             if (lightShape !== LIGHTSHAPE_PUNCTUAL) {
-                                lightSizeArg = `vec2(length(light${i}_halfWidth), length(light${i}_halfHeight)) * light${i}_size`;
+                                penumbraSizeArg = `vec2(length(light${i}_halfWidth), length(light${i}_halfHeight)) * light${i}_shadowSearchArea`;
                             }
-                            shadowCoordArgs = `${shadowCoordArgs}, light${i}_cameraParams, ${lightSizeArg}, dLightDirW`;
+                            shadowCoordArgs = `${shadowCoordArgs}, light${i}_cameraParams, ${penumbraSizeArg}, dLightDirW`;
                         }
 
                         if (lightType === LIGHTTYPE_OMNI) {


Test patch:
diff --git a/test/framework/anim/evaluator/anim-clip.test.mjs b/test/framework/anim/evaluator/anim-clip.test.mjs
index 075c239a5..a70458bb1 100644
--- a/test/framework/anim/evaluator/anim-clip.test.mjs
+++ b/test/framework/anim/evaluator/anim-clip.test.mjs
@@ -19,7 +19,9 @@ describe('AnimClip', function () {
             { name: 'event3', time: 1.5 }
         ]);
         const animTrack = new AnimTrack('track', 2, inputs, outputs, curves, animEvents);
-        animClip = new AnimClip(animTrack, 0, 1, true, false);
+        animClip = new AnimClip(animTrack, 0, 1, true, true, {
+            fire: () => {}
+        });
     });
 
     describe('#constructor', function () {
@@ -30,7 +32,7 @@ describe('AnimClip', function () {
             expect(animClip.track.name).to.equal('track');
             expect(animClip.snapshot._name).to.equal('trackSnapshot');
             expect(animClip.time).to.equal(0);
-            expect(animClip.loop).to.equal(false);
+            expect(animClip.loop).to.equal(true);
             expect(animClip.eventCursor).to.equal(0);
         });
 
@@ -102,7 +104,7 @@ describe('AnimClip', function () {
 
     describe('#time', function () {
 
-        it('updates the clips eventCursor property', function () {
+        it('aligns the clips eventCursor property when setting the time', function () {
             expect(animClip.eventCursor).to.equal(0);
             animClip.time = 1.1;
             expect(animClip.eventCursor).to.equal(2);
@@ -112,6 +114,42 @@ describe('AnimClip', function () {
             expect(animClip.eventCursor).to.equal(0);
         });
 
+        it('updates the clips eventCursor property as the clip updates forwards', function () {
+            expect(animClip.time).to.equal(0);
+            expect(animClip.eventCursor).to.equal(0);
+            animClip._update(0.55);
+            expect(animClip.time).to.equal(0.55);
+            expect(animClip.eventCursor).to.equal(1);
+            animClip._update(0.5);
+            expect(animClip.time).to.equal(1.05);
+            expect(animClip.eventCursor).to.equal(2);
+            animClip._update(0.5);
+            expect(animClip.time).to.equal(1.55);
+            expect(animClip.eventCursor).to.equal(0);
+            animClip._update(0.45);
+            expect(animClip.time).to.equal(2);
+            expect(animClip.eventCursor).to.equal(0);
+        });
+
+        it('updates the clips eventCursor property as the clip updates backwards', function () {
+            animClip.speed = -1;
+            animClip.time = 2;
+            expect(animClip.time).to.equal(2);
+            expect(animClip.eventCursor).to.equal(2);
+            animClip._update(0.45);
+            expect(animClip.time).to.equal(1.55);
+            expect(animClip.eventCursor).to.equal(2);
+            animClip._update(0.5);
+            expect(animClip.time).to.equal(1.05);
+            expect(animClip.eventCursor).to.equal(1);
+            animClip._update(0.5);
+            expect(animClip.time).to.equal(0.55);
+            expect(animClip.eventCursor).to.equal(0);
+            animClip._update(0.55);
+            expect(animClip.time).to.equal(0);
+            expect(animClip.eventCursor).to.equal(2);
+        });
+
     });
 
 });