Repository: playcanvas/engine
Pull request #5694 (2023-09-28 18:17:00)

fix array cloning
Fixes #5663

(in case @chrissterling doesn't have a chance to fix it)

I confirm I have read the [contributing guidelines](https://github.com/playcanvas/engine/blob/master/.github/CONTRIBUTING.md) and signed the [Contributor License Agreement](https://docs.google.com/a/playcanvas.com/forms/d/1Ih69zQfJG-QDLIEpHr6CsaAs6fPORNOVnMv5nuo0cjk/viewform).



Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 79e55963f..03013fe63 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -21,7 +21,7 @@ jobs:
     timeout-minutes: 10
     steps:
     - name: Checkout code
-      uses: actions/checkout@v3
+      uses: actions/checkout@v4
 
     - name: Setup Node.js 18.x
       uses: actions/setup-node@v3
@@ -41,7 +41,7 @@ jobs:
     timeout-minutes: 10
     steps:
     - name: Checkout code
-      uses: actions/checkout@v3
+      uses: actions/checkout@v4
 
     - name: Setup Node.js 18.x
       uses: actions/setup-node@v3
@@ -61,7 +61,7 @@ jobs:
     timeout-minutes: 10
     steps:
     - name: Checkout code
-      uses: actions/checkout@v3
+      uses: actions/checkout@v4
 
     - name: Setup Node.js 18.x
       uses: actions/setup-node@v3
@@ -81,7 +81,7 @@ jobs:
     timeout-minutes: 10
     steps:
     - name: Checkout code
-      uses: actions/checkout@v3
+      uses: actions/checkout@v4
 
     - name: Setup Node.js 18.x
       uses: actions/setup-node@v3
@@ -104,7 +104,7 @@ jobs:
     timeout-minutes: 10
     steps:
     - name: Checkout code
-      uses: actions/checkout@v3
+      uses: actions/checkout@v4
 
     - name: Setup Node.js 18.x
       uses: actions/setup-node@v3
@@ -124,7 +124,7 @@ jobs:
     timeout-minutes: 10
     steps:
     - name: Checkout code
-      uses: actions/checkout@v3
+      uses: actions/checkout@v4
 
     - name: Setup Node.js 18.x
       uses: actions/setup-node@v3
@@ -150,7 +150,7 @@ jobs:
     timeout-minutes: 10
     steps:
     - name: Checkout code
-      uses: actions/checkout@v3
+      uses: actions/checkout@v4
 
     - name: Setup Node.js 18.x
       uses: actions/setup-node@v3
diff --git a/examples/src/examples/graphics/asset-viewer.tsx b/examples/src/examples/graphics/asset-viewer.tsx
index c1d514cf6..7b296e648 100644
--- a/examples/src/examples/graphics/asset-viewer.tsx
+++ b/examples/src/examples/graphics/asset-viewer.tsx
@@ -7,6 +7,7 @@ import { Observer } from '@playcanvas/observer';
 class AssetViewerExample {
     static CATEGORY = 'Graphics';
     static NAME = 'Asset Viewer';
+    static WEBGPU_ENABLED = true;
 
     controls(data: Observer) {
         return <>
diff --git a/examples/src/examples/misc/mini-stats.tsx b/examples/src/examples/misc/mini-stats.tsx
index f08291681..a0db6e00f 100644
--- a/examples/src/examples/misc/mini-stats.tsx
+++ b/examples/src/examples/misc/mini-stats.tsx
@@ -1,9 +1,9 @@
 import * as pc from '../../../../';
 
-
 class MiniStatsExample {
     static CATEGORY = 'Misc';
     static NAME = 'Mini Stats';
+    static WEBGPU_ENABLED = true;
     static ENGINE = 'PERFORMANCE';
     static MINISTATS = true;
 
diff --git a/examples/src/examples/user-interface/text-emojis.tsx b/examples/src/examples/user-interface/text-emojis.tsx
index 84214f68e..c235141df 100644
--- a/examples/src/examples/user-interface/text-emojis.tsx
+++ b/examples/src/examples/user-interface/text-emojis.tsx
@@ -3,6 +3,7 @@ import * as pc from '../../../../';
 class TextEmojisExample {
     static CATEGORY = 'User Interface';
     static NAME = 'Text Emojis';
+    static WEBGPU_ENABLED = true;
 
     example(canvas: HTMLCanvasElement, deviceType: string): void {
 
diff --git a/examples/src/iframe/index.html b/examples/src/iframe/index.html
index a7fa3c8be..3097a52c8 100644
--- a/examples/src/iframe/index.html
+++ b/examples/src/iframe/index.html
@@ -132,7 +132,7 @@
             }
 
             const deviceType = app.graphicsDevice.deviceType;
-            if (deviceType !== 'webgpu' && deviceType !== 'null' && !Example.MINISTATS) {
+            if (deviceType !== 'null' && !Example.MINISTATS) {
                 // set up miniStats
                 var miniStats = new pcx.MiniStats(app);
                 if (urlParams.get('miniStats') === 'false') {
diff --git a/examples/src/lib/glslang/glslang.js b/examples/src/lib/glslang/glslang.js
index 4f3de6dcd..21bd32eaf 100644
--- a/examples/src/lib/glslang/glslang.js
+++ b/examples/src/lib/glslang/glslang.js
@@ -1,166 +1,78 @@
-(function webpackUniversalModuleDefinition(root, factory) {
-  if (typeof exports === 'object' && typeof module === 'object')
-    module.exports = factory();
-  else if (typeof define === 'function' && define.amd)
-    define("glslang", [], factory);
-  else if (typeof exports === 'object')
-    exports["glslang"] = factory();
-  else
-    root["glslang"] = factory();
-})(typeof self !== 'undefined' ? self : this, function () {
-  return /******/ (function (modules) { // webpackBootstrap
-    /******/ 	// The module cache
-    /******/
-    var installedModules = {};
-    /******/
-    /******/ 	// The require function
-    /******/
-    function __webpack_require__(moduleId) {
-      /******/
-      /******/ 		// Check if module is in cache
-      /******/
-      if (installedModules[moduleId]) {
-        /******/
-        return installedModules[moduleId].exports;
-        /******/
-      }
-      /******/ 		// Create a new module (and put it into the cache)
-      /******/
-      var module = installedModules[moduleId] = {
-        /******/            i: moduleId,
-        /******/            l: false,
-        /******/            exports: {}
-        /******/
-      };
-      /******/
-      /******/ 		// Execute the module function
-      /******/
-      modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
-      /******/
-      /******/ 		// Flag the module as loaded
-      /******/
-      module.l = true;
-      /******/
-      /******/ 		// Return the exports of the module
-      /******/
-      return module.exports;
-      /******/
-    }
 
-    /******/
-    /******/
-    /******/ 	// expose the modules object (__webpack_modules__)
-    /******/
-    __webpack_require__.m = modules;
-    /******/
-    /******/ 	// expose the module cache
-    /******/
-    __webpack_require__.c = installedModules;
-    /******/
-    /******/ 	// define getter function for harmony exports
-    /******/
-    __webpack_require__.d = function (exports, name, getter) {
-      /******/
-      if (!__webpack_require__.o(exports, name)) {
-        /******/
-        Object.defineProperty(exports, name, {enumerable: true, get: getter});
-        /******/
-      }
-      /******/
-    };
-    /******/
-    /******/ 	// define __esModule on exports
-    /******/
-    __webpack_require__.r = function (exports) {
-      /******/
-      if (typeof Symbol !== 'undefined' && Symbol.toStringTag) {
-        /******/
-        Object.defineProperty(exports, Symbol.toStringTag, {value: 'Module'});
-        /******/
-      }
-      /******/
-      Object.defineProperty(exports, '__esModule', {value: true});
-      /******/
-    };
-    /******/
-    /******/ 	// create a fake namespace object
-    /******/ 	// mode & 1: value is a module id, require it
-    /******/ 	// mode & 2: merge all properties of value into the ns
-    /******/ 	// mode & 4: return value when already ns object
-    /******/ 	// mode & 8|1: behave like require
-    /******/
-    __webpack_require__.t = function (value, mode) {
-      /******/
-      if (mode & 1) value = __webpack_require__(value);
-      /******/
-      if (mode & 8) return value;
-      /******/
-      if ((mode & 4) && typeof value === 'object' && value && value.__esModule) return value;
-      /******/
-      var ns = Object.create(null);
-      /******/
-      __webpack_require__.r(ns);
-      /******/
-      Object.defineProperty(ns, 'default', {enumerable: true, value: value});
-      /******/
-      if (mode & 2 && typeof value != 'string') for (var key in value) __webpack_require__.d(ns, key, function (key) {
-        return value[key];
-      }.bind(null, key));
-      /******/
-      return ns;
-      /******/
-    };
-    /******/
-    /******/ 	// getDefaultExport function for compatibility with non-harmony modules
-    /******/
-    __webpack_require__.n = function (module) {
-      /******/
-      var getter = module && module.__esModule ?
-        /******/            function getDefault() {
-          return module['default'];
-        } :
-        /******/            function getModuleExports() {
-          return module;
-        };
-      /******/
-      __webpack_require__.d(getter, 'a', getter);
-      /******/
-      return getter;
-      /******/
+var Module = (function() {
+  var _scriptDir = typeof document !== 'undefined' && document.currentScript ? document.currentScript.src : undefined;
+  
+  return (
+function(Module) {
+  Module = Module || {};
+
+var c;c||(c=typeof Module !== 'undefined' ? Module : {});
+c.compileGLSLZeroCopy=function(a,b,d,e){d=!!d;switch(b){case "vertex":var g=0;break;case "fragment":g=4;break;case "compute":g=5;break;default:throw Error("shader_stage must be 'vertex', 'fragment', or 'compute'.");}switch(e||"1.0"){case "1.0":var f=65536;break;case "1.1":f=65792;break;case "1.2":f=66048;break;case "1.3":f=66304;break;case "1.4":f=66560;break;case "1.5":f=66816;break;default:throw Error("spirv_version must be '1.0' ~ '1.5'.");}e=c._malloc(4);b=c._malloc(4);var h=aa([a,g,d,f,e,b]);
+d=k(e);a=k(b);c._free(e);c._free(b);if(0===h)throw Error("GLSL compilation failed");e={};d/=4;e.data=c.HEAPU32.subarray(d,d+a);e.free=function(){c._destroy_output_buffer(h)};return e};c.compileGLSL=function(a,b,d,e){a=c.compileGLSLZeroCopy(a,b,d,e);b=a.data.slice();a.free();return b};var p={},q;for(q in c)c.hasOwnProperty(q)&&(p[q]=c[q]);var r="./this.program",t=!1,u=!1;t="object"===typeof window;u="function"===typeof importScripts;var v="",w;
+if(t||u)u?v=self.location.href:document.currentScript&&(v=document.currentScript.src),_scriptDir&&(v=_scriptDir),0!==v.indexOf("blob:")?v=v.substr(0,v.lastIndexOf("/")+1):v="",u&&(w=function(a){var b=new XMLHttpRequest;b.open("GET",a,!1);b.responseType="arraybuffer";b.send(null);return new Uint8Array(b.response)});var x=c.print||console.log.bind(console),y=c.printErr||console.warn.bind(console);for(q in p)p.hasOwnProperty(q)&&(c[q]=p[q]);p=null;c.thisProgram&&(r=c.thisProgram);var A;
+c.wasmBinary&&(A=c.wasmBinary);"object"!==typeof WebAssembly&&y("no native wasm support detected");function k(a){var b="i32";"*"===b.charAt(b.length-1)&&(b="i32");switch(b){case "i1":return B[a>>0];case "i8":return B[a>>0];case "i16":return ba[a>>1];case "i32":return C[a>>2];case "i64":return C[a>>2];case "float":return ca[a>>2];case "double":return da[a>>3];default:D("invalid type for getValue: "+b)}return null}var E,ea=new WebAssembly.Table({initial:859,maximum:859,element:"anyfunc"}),fa=!1;
+function ha(){var a=c._convert_glsl_to_spirv;a||D("Assertion failed: Cannot call unknown function convert_glsl_to_spirv, make sure it is exported");return a}
+function aa(a){var b="string number boolean number number number".split(" "),d={string:function(a){var b=0;if(null!==a&&void 0!==a&&0!==a){var d=(a.length<<2)+1;b=G(d);ia(a,H,b,d)}return b},array:function(a){var b=G(a.length);B.set(a,b);return b}},e=ha(),g=[],f=0;if(a)for(var h=0;h<a.length;h++){var n=d[b[h]];n?(0===f&&(f=ja()),g[h]=n(a[h])):g[h]=a[h]}a=e.apply(null,g);0!==f&&ka(f);return a}var la="undefined"!==typeof TextDecoder?new TextDecoder("utf8"):void 0;
+function I(a,b,d){var e=b+d;for(d=b;a[d]&&!(d>=e);)++d;if(16<d-b&&a.subarray&&la)return la.decode(a.subarray(b,d));for(e="";b<d;){var g=a[b++];if(g&128){var f=a[b++]&63;if(192==(g&224))e+=String.fromCharCode((g&31)<<6|f);else{var h=a[b++]&63;g=224==(g&240)?(g&15)<<12|f<<6|h:(g&7)<<18|f<<12|h<<6|a[b++]&63;65536>g?e+=String.fromCharCode(g):(g-=65536,e+=String.fromCharCode(55296|g>>10,56320|g&1023))}}else e+=String.fromCharCode(g)}return e}
+function ia(a,b,d,e){if(0<e){e=d+e-1;for(var g=0;g<a.length;++g){var f=a.charCodeAt(g);if(55296<=f&&57343>=f){var h=a.charCodeAt(++g);f=65536+((f&1023)<<10)|h&1023}if(127>=f){if(d>=e)break;b[d++]=f}else{if(2047>=f){if(d+1>=e)break;b[d++]=192|f>>6}else{if(65535>=f){if(d+2>=e)break;b[d++]=224|f>>12}else{if(d+3>=e)break;b[d++]=240|f>>18;b[d++]=128|f>>12&63}b[d++]=128|f>>6&63}b[d++]=128|f&63}}b[d]=0}}"undefined"!==typeof TextDecoder&&new TextDecoder("utf-16le");var J,B,H,ba,C,ca,da;
+function ma(a){J=a;c.HEAP8=B=new Int8Array(a);c.HEAP16=ba=new Int16Array(a);c.HEAP32=C=new Int32Array(a);c.HEAPU8=H=new Uint8Array(a);c.HEAPU16=new Uint16Array(a);c.HEAPU32=new Uint32Array(a);c.HEAPF32=ca=new Float32Array(a);c.HEAPF64=da=new Float64Array(a)}var na=c.TOTAL_MEMORY||16777216;c.wasmMemory?E=c.wasmMemory:E=new WebAssembly.Memory({initial:na/65536});E&&(J=E.buffer);na=J.byteLength;ma(J);C[84916]=5582704;
+function K(a){for(;0<a.length;){var b=a.shift();if("function"==typeof b)b();else{var d=b.J;"number"===typeof d?void 0===b.H?c.dynCall_v(d):c.dynCall_vi(d,b.H):d(void 0===b.H?null:b.H)}}}var oa=[],pa=[],qa=[],ra=[];function sa(){var a=c.preRun.shift();oa.unshift(a)}var L=0,M=null,N=null;c.preloadedImages={};c.preloadedAudios={};function D(a){if(c.onAbort)c.onAbort(a);x(a);y(a);fa=!0;throw new WebAssembly.RuntimeError("abort("+a+"). Build with -s ASSERTIONS=1 for more info.");}
+function ta(){var a=O;return String.prototype.startsWith?a.startsWith("data:application/octet-stream;base64,"):0===a.indexOf("data:application/octet-stream;base64,")}var O="glslang.wasm";if(!ta()){var ua=O;O=c.locateFile?c.locateFile(ua,v):v+ua}function wa(){try{if(A)return new Uint8Array(A);if(w)return w(O);throw"both async and sync fetching of the wasm failed";}catch(a){D(a)}}
+function xa(){return A||!t&&!u||"function"!==typeof fetch?new Promise(function(a){a(wa())}):fetch(O,{credentials:"same-origin"}).then(function(a){if(!a.ok)throw"failed to load wasm binary file at '"+O+"'";return a.arrayBuffer()}).catch(function(){return wa()})}pa.push({J:function(){ya()}});var za=[null,[],[]],P=0;function Aa(){P+=4;return C[P-4>>2]}var Q={},Ba={};
+function Ca(){if(!R){var a={USER:"web_user",LOGNAME:"web_user",PATH:"/",PWD:"/",HOME:"/home/web_user",LANG:("object"===typeof navigator&&navigator.languages&&navigator.languages[0]||"C").replace("-","_")+".UTF-8",_:r},b;for(b in Ba)a[b]=Ba[b];var d=[];for(b in a)d.push(b+"="+a[b]);R=d}return R}var R;function S(a){return 0===a%4&&(0!==a%100||0===a%400)}function T(a,b){for(var d=0,e=0;e<=b;d+=a[e++]);return d}var U=[31,29,31,30,31,30,31,31,30,31,30,31],W=[31,28,31,30,31,30,31,31,30,31,30,31];
+function X(a,b){for(a=new Date(a.getTime());0<b;){var d=a.getMonth(),e=(S(a.getFullYear())?U:W)[d];if(b>e-a.getDate())b-=e-a.getDate()+1,a.setDate(1),11>d?a.setMonth(d+1):(a.setMonth(0),a.setFullYear(a.getFullYear()+1));else{a.setDate(a.getDate()+b);break}}return a}
+function Da(a,b,d,e){function g(a,b,d){for(a="number"===typeof a?a.toString():a||"";a.length<b;)a=d[0]+a;return a}function f(a,b){return g(a,b,"0")}function h(a,b){function V(a){return 0>a?-1:0<a?1:0}var d;0===(d=V(a.getFullYear()-b.getFullYear()))&&0===(d=V(a.getMonth()-b.getMonth()))&&(d=V(a.getDate()-b.getDate()));return d}function n(a){switch(a.getDay()){case 0:return new Date(a.getFullYear()-1,11,29);case 1:return a;case 2:return new Date(a.getFullYear(),0,3);case 3:return new Date(a.getFullYear(),
+0,2);case 4:return new Date(a.getFullYear(),0,1);case 5:return new Date(a.getFullYear()-1,11,31);case 6:return new Date(a.getFullYear()-1,11,30)}}function z(a){a=X(new Date(a.A+1900,0,1),a.G);var b=n(new Date(a.getFullYear()+1,0,4));return 0>=h(n(new Date(a.getFullYear(),0,4)),a)?0>=h(b,a)?a.getFullYear()+1:a.getFullYear():a.getFullYear()-1}var m=C[e+40>>2];e={N:C[e>>2],M:C[e+4>>2],D:C[e+8>>2],C:C[e+12>>2],B:C[e+16>>2],A:C[e+20>>2],F:C[e+24>>2],G:C[e+28>>2],X:C[e+32>>2],L:C[e+36>>2],O:m?m?I(H,m,void 0):
+"":""};d=d?I(H,d,void 0):"";m={"%c":"%a %b %d %H:%M:%S %Y","%D":"%m/%d/%y","%F":"%Y-%m-%d","%h":"%b","%r":"%I:%M:%S %p","%R":"%H:%M","%T":"%H:%M:%S","%x":"%m/%d/%y","%X":"%H:%M:%S","%Ec":"%c","%EC":"%C","%Ex":"%m/%d/%y","%EX":"%H:%M:%S","%Ey":"%y","%EY":"%Y","%Od":"%d","%Oe":"%e","%OH":"%H","%OI":"%I","%Om":"%m","%OM":"%M","%OS":"%S","%Ou":"%u","%OU":"%U","%OV":"%V","%Ow":"%w","%OW":"%W","%Oy":"%y"};for(var l in m)d=d.replace(new RegExp(l,"g"),m[l]);var F="Sunday Monday Tuesday Wednesday Thursday Friday Saturday".split(" "),
+va="January February March April May June July August September October November December".split(" ");m={"%a":function(a){return F[a.F].substring(0,3)},"%A":function(a){return F[a.F]},"%b":function(a){return va[a.B].substring(0,3)},"%B":function(a){return va[a.B]},"%C":function(a){return f((a.A+1900)/100|0,2)},"%d":function(a){return f(a.C,2)},"%e":function(a){return g(a.C,2," ")},"%g":function(a){return z(a).toString().substring(2)},"%G":function(a){return z(a)},"%H":function(a){return f(a.D,2)},
+"%I":function(a){a=a.D;0==a?a=12:12<a&&(a-=12);return f(a,2)},"%j":function(a){return f(a.C+T(S(a.A+1900)?U:W,a.B-1),3)},"%m":function(a){return f(a.B+1,2)},"%M":function(a){return f(a.M,2)},"%n":function(){return"\n"},"%p":function(a){return 0<=a.D&&12>a.D?"AM":"PM"},"%S":function(a){return f(a.N,2)},"%t":function(){return"\t"},"%u":function(a){return a.F||7},"%U":function(a){var b=new Date(a.A+1900,0,1),d=0===b.getDay()?b:X(b,7-b.getDay());a=new Date(a.A+1900,a.B,a.C);return 0>h(d,a)?f(Math.ceil((31-
+d.getDate()+(T(S(a.getFullYear())?U:W,a.getMonth()-1)-31)+a.getDate())/7),2):0===h(d,b)?"01":"00"},"%V":function(a){var b=n(new Date(a.A+1900,0,4)),d=n(new Date(a.A+1901,0,4)),e=X(new Date(a.A+1900,0,1),a.G);return 0>h(e,b)?"53":0>=h(d,e)?"01":f(Math.ceil((b.getFullYear()<a.A+1900?a.G+32-b.getDate():a.G+1-b.getDate())/7),2)},"%w":function(a){return a.F},"%W":function(a){var b=new Date(a.A,0,1),d=1===b.getDay()?b:X(b,0===b.getDay()?1:7-b.getDay()+1);a=new Date(a.A+1900,a.B,a.C);return 0>h(d,a)?f(Math.ceil((31-
+d.getDate()+(T(S(a.getFullYear())?U:W,a.getMonth()-1)-31)+a.getDate())/7),2):0===h(d,b)?"01":"00"},"%y":function(a){return(a.A+1900).toString().substring(2)},"%Y":function(a){return a.A+1900},"%z":function(a){a=a.L;var b=0<=a;a=Math.abs(a)/60;return(b?"+":"-")+String("0000"+(a/60*100+a%60)).slice(-4)},"%Z":function(a){return a.O},"%%":function(){return"%"}};for(l in m)0<=d.indexOf(l)&&(d=d.replace(new RegExp(l,"g"),m[l](e)));l=Ea(d);if(l.length>b)return 0;B.set(l,a);return l.length-1}
+function Ea(a){for(var b=0,d=0;d<a.length;++d){var e=a.charCodeAt(d);55296<=e&&57343>=e&&(e=65536+((e&1023)<<10)|a.charCodeAt(++d)&1023);127>=e?++b:b=2047>=e?b+2:65535>=e?b+3:b+4}b=Array(b+1);ia(a,b,0,b.length);return b}
+var Ga={f:function(){},c:function(){c.___errno_location&&(C[c.___errno_location()>>2]=63);return-1},n:function(a,b){P=b;try{var d=Aa();var e=Aa();if(-1===d||0===e)var g=-28;else{var f=Q.K[d];if(f&&e===f.U){var h=(void 0).T(f.S);Q.R(d,h,e,f.flags,f.offset);(void 0).W(h);Q.K[d]=null;f.P&&Fa(f.V)}g=0}return g}catch(n){return D(n),-n.I}},a:function(){},b:function(){D()},k:function(a,b,d){H.set(H.subarray(b,b+d),a)},l:function(a){var b=B.length;if(2147418112<a)return!1;for(var d=1;4>=d;d*=2){var e=b*(1+
+.2/d);e=Math.min(e,a+100663296);e=Math.max(16777216,a,e);0<e%65536&&(e+=65536-e%65536);a:{try{E.grow(Math.min(2147418112,e)-J.byteLength+65535>>16);ma(E.buffer);var g=1;break a}catch(f){}g=void 0}if(g)return!0}return!1},d:function(a,b){var d=0;Ca().forEach(function(e,g){var f=b+d;g=C[a+4*g>>2]=f;for(f=0;f<e.length;++f)B[g++>>0]=e.charCodeAt(f);B[g>>0]=0;d+=e.length+1});return 0},e:function(a,b){var d=Ca();C[a>>2]=d.length;var e=0;d.forEach(function(a){e+=a.length+1});C[b>>2]=e;return 0},h:function(){return 0},
+j:function(){return 0},g:function(a,b,d,e){try{for(var g=0,f=0;f<d;f++){for(var h=C[b+8*f>>2],n=C[b+(8*f+4)>>2],z=0;z<n;z++){var m=H[h+z],l=za[a];0===m||10===m?((1===a?x:y)(I(l,0)),l.length=0):l.push(m)}g+=n}C[e>>2]=g;return 0}catch(F){return D(F),F.I}},memory:E,o:function(){},i:function(){},m:function(a,b,d,e){return Da(a,b,d,e)},table:ea},Ha=function(){function a(a){c.asm=a.exports;L--;c.monitorRunDependencies&&c.monitorRunDependencies(L);0==L&&(null!==M&&(clearInterval(M),M=null),N&&(a=N,N=null,
+a()))}function b(b){a(b.instance)}function d(a){return xa().then(function(a){return WebAssembly.instantiate(a,e)}).then(a,function(a){y("failed to asynchronously prepare wasm: "+a);D(a)})}var e={env:Ga,wasi_snapshot_preview1:Ga};L++;c.monitorRunDependencies&&c.monitorRunDependencies(L);if(c.instantiateWasm)try{return c.instantiateWasm(e,a)}catch(g){return y("Module.instantiateWasm callback failed with error: "+g),!1}(function(){if(A||"function"!==typeof WebAssembly.instantiateStreaming||ta()||"function"!==
+typeof fetch)return d(b);fetch(O,{credentials:"same-origin"}).then(function(a){return WebAssembly.instantiateStreaming(a,e).then(b,function(a){y("wasm streaming compile failed: "+a);y("falling back to ArrayBuffer instantiation");d(b)})})})();return{}}();c.asm=Ha;var ya=c.___wasm_call_ctors=function(){return(ya=c.___wasm_call_ctors=c.asm.p).apply(null,arguments)};c._convert_glsl_to_spirv=function(){return(c._convert_glsl_to_spirv=c.asm.q).apply(null,arguments)};
+c._destroy_output_buffer=function(){return(c._destroy_output_buffer=c.asm.r).apply(null,arguments)};c._malloc=function(){return(c._malloc=c.asm.s).apply(null,arguments)};var Fa=c._free=function(){return(Fa=c._free=c.asm.t).apply(null,arguments)},ja=c.stackSave=function(){return(ja=c.stackSave=c.asm.u).apply(null,arguments)},G=c.stackAlloc=function(){return(G=c.stackAlloc=c.asm.v).apply(null,arguments)},ka=c.stackRestore=function(){return(ka=c.stackRestore=c.asm.w).apply(null,arguments)};
+c.dynCall_vi=function(){return(c.dynCall_vi=c.asm.x).apply(null,arguments)};c.dynCall_v=function(){return(c.dynCall_v=c.asm.y).apply(null,arguments)};c.asm=Ha;var Y;c.then=function(a){if(Y)a(c);else{var b=c.onRuntimeInitialized;c.onRuntimeInitialized=function(){b&&b();a(c)}}return c};N=function Ia(){Y||Z();Y||(N=Ia)};
+function Z(){function a(){if(!Y&&(Y=!0,!fa)){K(pa);K(qa);if(c.onRuntimeInitialized)c.onRuntimeInitialized();if(c.postRun)for("function"==typeof c.postRun&&(c.postRun=[c.postRun]);c.postRun.length;){var a=c.postRun.shift();ra.unshift(a)}K(ra)}}if(!(0<L)){if(c.preRun)for("function"==typeof c.preRun&&(c.preRun=[c.preRun]);c.preRun.length;)sa();K(oa);0<L||(c.setStatus?(c.setStatus("Running..."),setTimeout(function(){setTimeout(function(){c.setStatus("")},1);a()},1)):a())}}c.run=Z;
+if(c.preInit)for("function"==typeof c.preInit&&(c.preInit=[c.preInit]);0<c.preInit.length;)c.preInit.pop()();Z();
+
+
+  return Module
+}
+);
+})();
+if (typeof exports === 'object' && typeof module === 'object')
+      module.exports = Module;
+    else if (typeof define === 'function' && define['amd'])
+      define([], function() { return Module; });
+    else if (typeof exports === 'object')
+      exports["Module"] = Module;
+    export default (() => {
+    const initialize = () => {
+        return new Promise(resolve => {
+            Module({
+                locateFile() {
+                    const i = import.meta.url.lastIndexOf('/')
+                    return import.meta.url.substring(0, i) + '/glslang.wasm';
+                },
+                onRuntimeInitialized() {
+                    resolve({
+                        compileGLSLZeroCopy: this.compileGLSLZeroCopy,
+                        compileGLSL: this.compileGLSL,
+                    });
+                },
+            });
+        });
     };
-    /******/
-    /******/ 	// Object.prototype.hasOwnProperty.call
-    /******/
-    __webpack_require__.o = function (object, property) {
-      return Object.prototype.hasOwnProperty.call(object, property);
+
+    let instance;
+    return () => {
+        if (!instance) {
+            instance = initialize();
+        }
+        return instance;
     };
-    /******/
-    /******/ 	// __webpack_public_path__
-    /******/
-    __webpack_require__.p = "";
-    /******/
-    /******/
-    /******/ 	// Load entry module and return exports
-    /******/
-    return __webpack_require__(__webpack_require__.s = 0);
-    /******/
-  })
-    /************************************************************************/
-    /******/ ([
-      /* 0 */
-      /***/ (function (module, __webpack_exports__, __webpack_require__) {
-        "use strict";
-        __webpack_require__.r(__webpack_exports__);
-        let glslang = undefined;
-        /* harmony default export */
-        __webpack_exports__["default"] = (async function () {
-          if (glslang !== undefined) return glslang;
-          // @ts-ignore
-          const glslangModule = await import(/* webpackIgnore: true */ 'https://unpkg.com/@webgpu/glslang@0.0.15/dist/web-devel/glslang.js');
-          glslang = await glslangModule.default();
-          return glslang;
-        });
-        /***/
-      })
-      /******/])["default"];
-});
+})();
diff --git a/extras/mini-stats/graph.js b/extras/mini-stats/graph.js
index 1dd16408e..58db419b7 100644
--- a/extras/mini-stats/graph.js
+++ b/extras/mini-stats/graph.js
@@ -1,6 +1,7 @@
 // Realtime performance graph visual
 class Graph {
     constructor(name, app, watermark, textRefreshRate, timer) {
+        this.app = app;
         this.name = name;
         this.device = app.graphicsDevice;
         this.timer = timer;
@@ -19,9 +20,13 @@ class Graph {
         this.sample = new Uint8ClampedArray(4);
         this.sample.set([0, 0, 0, 255]);
 
-        app.on('frameupdate', this.update.bind(this));
-
         this.counter = 0;
+
+        this.app.on('frameupdate', this.update, this);
+    }
+
+    destroy() {
+        this.app.off('frameupdate', this.update, this);
     }
 
     // called when context was lost, function releases all context related resources
@@ -55,7 +60,6 @@ class Graph {
             let value = 0;
             const range = 1.5 * this.watermark;
             for (let i = 0; i < timings.length; ++i) {
-
                 // scale the value into the range
                 value += Math.floor(timings[i] / range * 255);
                 this.sample[i] = value;
@@ -64,18 +68,10 @@ class Graph {
             // .a store watermark
             this.sample[3] = this.watermark / range * 255;
 
-            // write latest sample to the texture
-            const gl = this.device.gl;
-            this.device.bindTexture(this.texture);
-            gl.texSubImage2D(gl.TEXTURE_2D,
-                             0,
-                             this.cursor,
-                             this.yOffset,
-                             1,
-                             1,
-                             gl.RGBA,
-                             gl.UNSIGNED_BYTE,
-                             this.sample);
+            // write latest sample
+            const data = this.texture.lock();
+            data.set(this.sample, (this.cursor + this.yOffset * this.texture.width) * 4);
+            this.texture.unlock();
 
             // update cursor position
             this.cursor++;
@@ -86,14 +82,12 @@ class Graph {
     }
 
     render(render2d, x, y, w, h) {
-        render2d.quad(x + w,
-                      y,
-                      -w,
-                      h,
-                      this.cursor,
-                      0.5 + this.yOffset,
+        render2d.quad(x + w, y, -w, h,
+                      this.enabled ? this.cursor : 0,
+                      this.enabled ? 0.5 + this.yOffset : this.texture.height - 1,
                       -w, 0,
-                      this.enabled);
+                      this.texture,
+                      0);
     }
 }
 
diff --git a/extras/mini-stats/mini-stats.js b/extras/mini-stats/mini-stats.js
index 7e32ea723..211c3f8b9 100644
--- a/extras/mini-stats/mini-stats.js
+++ b/extras/mini-stats/mini-stats.js
@@ -1,8 +1,8 @@
 import {
+    ADDRESS_REPEAT,
     FILTER_NEAREST,
     LAYERID_UI,
     math,
-    Color,
     Texture
 } from 'playcanvas';
 import { CpuTimer } from './cpu-timer.js';
@@ -15,68 +15,28 @@ import { Render2d } from './render2d.js';
 // MiniStats rendering of CPU and GPU timing information
 class MiniStats {
     constructor(app, options) {
-
-        this.app = app;
-        this.drawLayer = app.scene.layers.getLayerById(LAYERID_UI);
-
         const device = app.graphicsDevice;
 
-        // handle context lost
-        this._contextLostHandler = (event) => {
-            event.preventDefault();
-
-            if (this.graphs) {
-                for (let i = 0; i < this.graphs.length; i++) {
-                    this.graphs[i].loseContext();
-                }
-            }
-        };
-        device.canvas.addEventListener('webglcontextlost', this._contextLostHandler, false);
-
         options = options || MiniStats.getDefaultOptions();
 
-        // create graphs based on options
-        const graphs = this.initGraphs(app, device, options);
-
-        // extract words needed
-        let words = ['', 'ms', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.'];
-
-        // graph names
-        graphs.forEach((graph) => {
-            words.push(graph.name);
-        });
+        // create graphs
+        this.initGraphs(app, device, options);
 
-        // stats units
-        if (options.stats) {
-            options.stats.forEach((stat) => {
-                if (stat.unitsName)
-                    words.push(stat.unitsName);
-            });
-        }
-
-        // remove duplicates
-        words = words.filter((item, index) => {
-            return words.indexOf(item) >= index;
-        });
-
-        // create word atlas
-        const maxWidth = options.sizes.reduce((max, v) => {
-            return v.width > max ? v.width : max;
-        }, 0);
-        const wordAtlasData = this.initWordAtlas(device, words, maxWidth, graphs.length);
-        const texture = wordAtlasData.texture;
-
-        // assign texture to graphs
-        graphs.forEach((graph, i) => {
-            graph.texture = texture;
-            graph.yOffset = i;
-        });
+        // extract list of words
+        const words = new Set(
+            ['', 'ms', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.']
+                .concat(this.graphs.map(graph => graph.name))
+                .concat(options.stats ? options.stats.map(stat => stat.unitsName) : [])
+                .filter(item => !!item)
+        );
 
+        this.wordAtlas = new WordAtlas(device, words);
         this.sizes = options.sizes;
         this._activeSizeIndex = options.startSizeIndex;
 
         // create click region so we can resize
         const div = document.createElement('div');
+        div.setAttribute('id', 'mini-stats');
         div.style.cssText = 'position:fixed;bottom:0;left:0;background:transparent;';
         document.body.appendChild(div);
 
@@ -85,7 +45,7 @@ class MiniStats {
         });
 
         div.addEventListener('mouseleave', (event) => {
-            this.opacity = 0.5;
+            this.opacity = 0.7;
         });
 
         div.addEventListener('click', (event) => {
@@ -96,21 +56,14 @@ class MiniStats {
             }
         });
 
-        device.on('resizecanvas', () => {
-            this.updateDiv();
-        });
-
-        app.on('postrender', () => {
-            if (this._enabled) {
-                this.render();
-            }
-        });
+        device.on('resizecanvas', this.updateDiv, this);
+        device.on('losecontext', this.loseContext, this);
+        app.on('postrender', this.postRender, this);
 
+        this.app = app;
+        this.drawLayer = app.scene.layers.getLayerById(LAYERID_UI);
         this.device = device;
-        this.texture = texture;
-        this.wordAtlas = wordAtlasData.atlas;
-        this.render2d = new Render2d(device, options.colors);
-        this.graphs = graphs;
+        this.render2d = new Render2d(device);
         this.div = div;
 
         this.width = 0;
@@ -124,6 +77,16 @@ class MiniStats {
         this.activeSizeIndex = this._activeSizeIndex;
     }
 
+    destroy() {
+        this.device.off('resizecanvas', this.updateDiv, this);
+        this.device.off('losecontext', this.loseContext, this);
+        this.app.off('postrender', this.postRender, this);
+
+        this.graphs.forEach(graph => graph.destroy());
+        this.wordAtlas.destroy();
+        this.texture.destroy();
+    }
+
     static getDefaultOptions() {
         return {
 
@@ -140,15 +103,6 @@ class MiniStats {
             // refresh rate of text stats in ms
             textRefreshRate: 500,
 
-            // colors used to render graphs
-            colors: {
-                graph0: new Color(0.7, 0.2, 0.2, 1),
-                graph1: new Color(0.2, 0.7, 0.2, 1),
-                graph2: new Color(0.2, 0.2, 0.7, 1),
-                watermark: new Color(0.4, 0.4, 0.2, 1),
-                background: new Color(0, 0, 0, 1.0)
-            },
-
             // cpu graph options
             cpu: {
                 enabled: true,
@@ -228,56 +182,48 @@ class MiniStats {
         return this._enabled;
     }
 
-    initWordAtlas(device, words, maxWidth, numGraphs) {
-
-        // create the texture for storing word atlas and graph data
-        const texture = new Texture(device, {
-            name: 'mini-stats',
-            width: math.nextPowerOfTwo(maxWidth),
-            height: 64,
-            mipmaps: false,
-            minFilter: FILTER_NEAREST,
-            magFilter: FILTER_NEAREST
-        });
-
-        const wordAtlas = new WordAtlas(texture, words);
-
-        const dest = texture.lock();
-        for (let i = 0; i < texture.width * numGraphs; ++i) {
-            dest.set([0, 0, 0, 255], i * 4);
-        }
-        texture.unlock();
-
-        // ensure texture is uploaded
-        device.setTexture(texture, 0);
-
-        return { atlas: wordAtlas, texture: texture };
-    }
-
     initGraphs(app, device, options) {
-        const graphs = [];
+        this.graphs = [];
 
         if (options.cpu.enabled) {
             const timer = new CpuTimer(app);
             const graph = new Graph('CPU', app, options.cpu.watermark, options.textRefreshRate, timer);
-            graphs.push(graph);
+            this.graphs.push(graph);
         }
 
         if (options.gpu.enabled) {
             const timer = new GpuTimer(device);
             const graph = new Graph('GPU', app, options.gpu.watermark, options.textRefreshRate, timer);
-            graphs.push(graph);
+            this.graphs.push(graph);
         }
 
         if (options.stats) {
             options.stats.forEach((entry) => {
                 const timer = new StatsTimer(app, entry.stats, entry.decimalPlaces, entry.unitsName, entry.multiplier);
                 const graph = new Graph(entry.name, app, entry.watermark, options.textRefreshRate, timer);
-                graphs.push(graph);
+                this.graphs.push(graph);
             });
         }
 
-        return graphs;
+        const maxWidth = options.sizes.reduce((max, v) => {
+            return v.width > max ? v.width : max;
+        }, 0);
+
+        this.texture = new Texture(device, {
+            name: 'mini-stats-graph-texture',
+            width: math.nextPowerOfTwo(maxWidth),
+            height: math.nextPowerOfTwo(this.graphs.length),
+            mipmaps: false,
+            minFilter: FILTER_NEAREST,
+            magFilter: FILTER_NEAREST,
+            addressU: ADDRESS_REPEAT,
+            addressV: ADDRESS_REPEAT
+        });
+
+        this.graphs.forEach((graph, i) => {
+            graph.texture = this.texture;
+            graph.yOffset = i;
+        });
     }
 
     render() {
@@ -318,7 +264,7 @@ class MiniStats {
             }
         }
 
-        render2d.render(this.app, this.drawLayer, this.texture, this.clr, height);
+        render2d.render(this.app, this.drawLayer, this.texture, this.wordAtlas.texture, this.clr, height);
     }
 
     resize(width, height, showGraphs) {
@@ -340,6 +286,16 @@ class MiniStats {
         this.div.style.width = this.width + 'px';
         this.div.style.height = this.overallHeight + 'px';
     }
+
+    loseContext() {
+        this.graphs.forEach(graph => graph.loseContext());
+    }
+
+    postRender() {
+        if (this._enabled) {
+            this.render();
+        }
+    }
 }
 
 export { MiniStats };
diff --git a/extras/mini-stats/render2d.js b/extras/mini-stats/render2d.js
index 9edf7372f..e83bdb496 100644
--- a/extras/mini-stats/render2d.js
+++ b/extras/mini-stats/render2d.js
@@ -23,56 +23,52 @@ import {
     GraphNode
 } from 'playcanvas';
 
+const vertexShader = /* glsl */ `
+attribute vec3 vertex_position;         // unnormalized xy, word flag
+attribute vec4 vertex_texCoord0;        // unnormalized texture space uv, normalized uv
+
+varying vec4 uv0;
+varying float wordFlag;
+
+void main(void) {
+    gl_Position = vec4(vertex_position.xy * 2.0 - 1.0, 0.5, 1.0);
+    uv0 = vertex_texCoord0;
+    wordFlag = vertex_position.z;
+}`;
+
+// this fragment shader renders the bits required for text and graphs. The text is identified
+// in the texture by white color. The graph data is specified as a single row of pixels
+// where the R channel denotes the height of the 1st graph and the G channel the height
+// of the second graph and B channel the height of the last graph
+const fragmentShader = /* glsl */ `
+varying vec4 uv0;
+varying float wordFlag;
+
+uniform vec4 clr;
+uniform sampler2D graphTex;
+uniform sampler2D wordsTex;
+
+void main (void) {
+    vec4 graphSample = texture2D(graphTex, uv0.xy);
+
+    vec4 graph;
+    if (uv0.w < graphSample.r)
+        graph = vec4(0.7, 0.2, 0.2, 1.0);
+    else if (uv0.w < graphSample.g)
+        graph = vec4(0.2, 0.7, 0.2, 1.0);
+    else if (uv0.w < graphSample.b)
+        graph = vec4(0.2, 0.2, 0.7, 1.0);
+    else
+        graph = vec4(0.0, 0.0, 0.0, 1.0 - 0.25 * sin(uv0.w * 3.14159));
+
+    vec4 words = texture2D(wordsTex, vec2(uv0.x, 1.0 - uv0.y));
+
+    gl_FragColor = mix(graph, words, wordFlag) * clr;
+}`;
+
 // render 2d textured quads
 class Render2d {
-    constructor(device, colors, maxQuads = 512) {
-        const vertexShader =
-            'attribute vec3 vertex_position;\n' +       // unnormalized
-            'attribute vec4 vertex_texCoord0;\n' +      // unnormalized texture space uv, normalized uv
-            'uniform vec4 screenAndTextureSize;\n' +    // xy: screen size, zw: texture size
-            'varying vec4 uv0;\n' +
-            'varying float enabled;\n' +
-            'void main(void) {\n' +
-            '    vec2 pos = vertex_position.xy / screenAndTextureSize.xy;\n' +
-            '    gl_Position = vec4(pos * 2.0 - 1.0, 0.5, 1.0);\n' +
-            '    uv0 = vec4(vertex_texCoord0.xy / screenAndTextureSize.zw, vertex_texCoord0.zw);\n' +
-            '    enabled = vertex_position.z;\n' +
-            '}\n';
-
-        // this fragment shader renders the bits required for text and graphs. The text is identified
-        // in the texture by white color. The graph data is specified as a single row of pixels
-        // where the R channel denotes the height of the 1st graph and the G channel the height
-        // of the second graph and B channel the height of the last graph
-        const fragmentShader =
-            'varying vec4 uv0;\n' +
-            'varying float enabled;\n' +
-            'uniform vec4 clr;\n' +
-            'uniform vec4 col0;\n' +
-            'uniform vec4 col1;\n' +
-            'uniform vec4 col2;\n' +
-            'uniform vec4 watermark;\n' +
-            'uniform float watermarkSize;\n' +
-            'uniform vec4 background;\n' +
-            'uniform sampler2D data;\n' +
-            'void main (void) {\n' +
-            '    vec4 tex = texture2D(data, uv0.xy);\n' +
-            '    if (!(tex.rgb == vec3(1.0, 1.0, 1.0))) {\n' +  // pure white is text
-            '       if (enabled < 0.5)\n' +
-            '           tex = background;\n' +
-            '       else if (abs(uv0.w - tex.a) < watermarkSize)\n' +
-            '           tex = watermark;\n' +
-            '       else if (uv0.w < tex.r)\n' +
-            '           tex = col0;\n' +
-            '       else if (uv0.w < tex.g)\n' +
-            '           tex = col1;\n' +
-            '       else if (uv0.w < tex.b)\n' +
-            '           tex = col2;\n' +
-            '       else\n' +
-            '           tex = background;\n' +
-            '    }\n' +
-            '    gl_FragColor = tex * clr;\n' +
-            '}\n';
-
+    constructor(device, maxQuads = 512) {
         const format = new VertexFormat(device, [
             { semantic: SEMANTIC_POSITION, components: 3, type: TYPE_FLOAT32 },
             { semantic: SEMANTIC_TEXCOORD0, components: 4, type: TYPE_FLOAT32 }
@@ -89,13 +85,12 @@ class Render2d {
             indices[i * 6 + 5] = i * 4 + 3;
         }
 
-        this.device = device;
         const shader = shaderChunks.createShaderFromCode(device, vertexShader, fragmentShader, 'mini-stats');
+
+        this.device = device;
         this.buffer = new VertexBuffer(device, format, maxQuads * 4, BUFFER_STREAM);
         this.data = new Float32Array(this.buffer.numBytes / 4);
-
         this.indexBuffer = new IndexBuffer(device, INDEXFORMAT_UINT16, maxQuads * 6, BUFFER_STATIC, indices);
-
         this.prim = {
             type: PRIMITIVE_TRIANGLES,
             indexed: true,
@@ -120,69 +115,61 @@ class Render2d {
 
         this.meshInstance = new MeshInstance(this.mesh, material, new GraphNode('MiniStatsMesh'));
 
-        // colors
-        const setupColor = (name, value) => {
-            this[name] = new Float32Array([value.r, value.g, value.b, value.a]);
+        this.uniforms = {
+            clr: new Float32Array(4)
         };
-        setupColor('col0', colors.graph0);
-        setupColor('col1', colors.graph1);
-        setupColor('col2', colors.graph2);
-        setupColor('watermark', colors.watermark);
-        setupColor('background', colors.background);
-
-        this.clr = new Float32Array(4);
-        this.screenTextureSize = new Float32Array(4);
-    }
-
-    quad(x, y, w, h, u, v, uw, uh, enabled) {
-        const quad = this.quads++;
 
-        // update primitive
-        this.prim.count += 6;
+        this.targetSize = {
+            width: device.width,
+            height: device.height
+        };
+    }
 
-        const x1 = x + w;
-        const y1 = y + h;
-        const u1 = u + (uw === undefined ? w : uw);
-        const v1 = v + (uh === undefined ? h : uh);
+    quad(x, y, w, h, u, v, uw, uh, texture, wordFlag = 0) {
+        const rw = this.targetSize.width;
+        const rh = this.targetSize.height;
+        const x0 = x / rw;
+        const y0 = y / rh;
+        const x1 = (x + w) / rw;
+        const y1 = (y + h) / rh;
+
+        const tw = texture.width;
+        const th = texture.height;
+        const u0 = u / tw;
+        const v0 = v / th;
+        const u1 = (u + (uw ?? w)) / tw;
+        const v1 = (v + (uh ?? h)) / th;
 
-        const colorize = enabled ? 1 : 0;
         this.data.set([
-            x,  y,  colorize, u,  v,  0, 0,
-            x1, y,  colorize, u1, v,  1, 0,
-            x1, y1, colorize, u1, v1, 1, 1,
-            x,  y1, colorize, u,  v1, 0, 1
-        ], 4 * 7 * quad);
+            x0, y0, wordFlag, u0, v0, 0, 0,
+            x1, y0, wordFlag, u1, v0, 1, 0,
+            x1, y1, wordFlag, u1, v1, 1, 1,
+            x0, y1, wordFlag, u0, v1, 0, 1
+        ], 4 * 7 * this.quads);
+
+        this.quads++;
+        this.prim.count += 6;
     }
 
     startFrame() {
-        this.prim.count = 0;
         this.quads = 0;
+        this.prim.count = 0;
+
+        this.targetSize.width = this.device.width;
+        this.targetSize.height = this.device.height;
     }
 
-    render(app, layer, texture, clr, height) {
+    render(app, layer, graphTexture, wordsTexture, clr, height) {
 
         // set vertex data (swap storage)
         this.buffer.setData(this.data.buffer);
 
+        this.uniforms.clr.set(clr, 0);
+
         // material params
-        this.clr.set(clr, 0);
-        this.material.setParameter('clr', this.clr);
-
-        this.material.setParameter('col0', this.col0);
-        this.material.setParameter('col1', this.col1);
-        this.material.setParameter('col2', this.col2);
-        this.material.setParameter('watermark', this.watermark);
-        this.material.setParameter('background', this.background);
-        this.material.setParameter('watermarkSize', 0.5 / height);
-
-        const canvas = this.device.canvas;
-        this.screenTextureSize[0] = canvas.scrollWidth;
-        this.screenTextureSize[1] = canvas.scrollHeight;
-        this.screenTextureSize[2] = texture.width;
-        this.screenTextureSize[3] = texture.height;
-        this.material.setParameter('screenAndTextureSize', this.screenTextureSize);
-
-        this.material.setParameter('data', texture);
+        this.material.setParameter('clr', this.uniforms.clr);
+        this.material.setParameter('graphTex', graphTexture);
+        this.material.setParameter('wordsTex', wordsTexture);
 
         app.drawMeshInstance(this.meshInstance, layer);
     }
diff --git a/extras/mini-stats/word-atlas.js b/extras/mini-stats/word-atlas.js
index 413761bb5..e5d48b5ff 100644
--- a/extras/mini-stats/word-atlas.js
+++ b/extras/mini-stats/word-atlas.js
@@ -1,94 +1,95 @@
-// Word atlas
+import { FILTER_NEAREST, math, Texture } from 'playcanvas';
+
 class WordAtlas {
-    constructor(texture, words) {
-        const canvas = document.createElement('canvas');
-        canvas.width = texture.width;
-        canvas.height = texture.height;
+    constructor(device, words) {
+
+        const initContext = (context) => {
+            context.font = '10px "Lucida Console", Monaco, monospace';
+            context.textAlign = 'left';
+            context.textBaseline = 'alphabetic';
+        };
 
-        // configure the context
+        const isNumber = (word) => {
+            return word === '.' || (word.length === 1 && word.charCodeAt(0) >= 48 && word.charCodeAt(0) <= 57);
+        };
+
+        // create a canvas
+        const canvas = document.createElement('canvas');
         const context = canvas.getContext('2d', { alpha: true });
-        context.font = '10px "Lucida Console", Monaco, monospace';
-        context.textAlign = 'left';
-        context.textBaseline = 'alphabetic';
-        context.fillStyle = 'rgb(255, 255, 255)';
+        initContext(context);
 
+        // measure words
+        const placements = new Map();
         const padding = 5;
+        const width = 512;
         let x = padding;
         let y = padding;
-        const placements = [];
-
-        for (let i = 0; i < words.length; ++i) {
-            // measure the word
-            const measurement = context.measureText(words[i]);
 
+        words.forEach((word) => {
+            const measurement = context.measureText(word);
             const l = Math.ceil(-measurement.actualBoundingBoxLeft);
             const r = Math.ceil(measurement.actualBoundingBoxRight);
             const a = Math.ceil(measurement.actualBoundingBoxAscent);
             const d = Math.ceil(measurement.actualBoundingBoxDescent);
-
             const w = l + r;
             const h = a + d;
 
-            // wrap text
-            if (x + w >= canvas.width) {
+            if (x + w + padding >= width) {
                 x = padding;
                 y += 16;
             }
 
+            placements.set(word, { l, r, a, d, w, h, x: x, y: y });
+
+            x += w + padding;
+        });
+
+        // size canvas
+        canvas.width = 512;
+        canvas.height = math.nextPowerOfTwo(y + 16 + padding);
+
+        initContext(context);
+        context.fillStyle = 'rgb(0, 0, 0)';
+        context.fillRect(0, 0, canvas.width, canvas.height);
+
+        // render words
+        placements.forEach((m, word) => {
             // digits and '.' are white, the rest grey
-            context.fillStyle = words[i].length === 1 ? 'rgb(255, 255, 255)' : 'rgb(150, 150, 150)';
+            context.fillStyle = isNumber(word) ? 'rgb(255, 255, 255)' : 'rgb(170, 170, 170)';
 
             // render the word
-            context.fillText(words[i], x - l, y + a);
-
-            placements.push({
-                l: l,
-                r: r,
-                a: a,
-                d: d,
-                x: x,
-                y: y,
-                w: w,
-                h: h
-            });
+            context.fillText(word, m.x - m.l, m.y + m.a);
+        });
 
-            x += w + padding;
+        this.placements = placements;
+
+        // convert from black and white data to white texture with alpha
+        const data = context.getImageData(0, 0, canvas.width, canvas.height).data;
+        for (let i = 0; i < data.length; i += 4) {
+            data[i + 3] = data[i + 0];
+            data[i + 0] = 255;
+            data[i + 1] = 255;
+            data[i + 2] = 255;
         }
 
-        const wordMap = { };
-        words.forEach((w, i) => {
-            wordMap[w] = i;
+        this.texture = new Texture(device, {
+            name: 'mini-stats-word-atlas',
+            width: canvas.width,
+            height: canvas.height,
+            mipmaps: false,
+            minFilter: FILTER_NEAREST,
+            magFilter: FILTER_NEAREST,
+            levels: [data]
         });
+    }
 
-        this.words = words;
-        this.wordMap = wordMap;
-        this.placements = placements;
-        this.texture = texture;
-
-        // copy pixel data to target
-        const source = context.getImageData(0, 0, canvas.width, canvas.height);
-        const dest = texture.lock();
-        for (let y = 0; y < source.height; ++y) {
-            for (let x = 0; x < source.width; ++x) {
-                const offset = (x + y * texture.width) * 4;
-
-                // set .rgb white to allow shader to identify text
-                dest[offset] = 255;
-                dest[offset + 1] = 255;
-                dest[offset + 2] = 255;
-
-                // red and alpha from image
-                const red = source.data[(x + (source.height - 1 - y) * source.width) * 4];
-                const alpha = source.data[(x + (source.height - 1 - y) * source.width) * 4 + 3];
-
-                // alpha contains grayscale letters, use red to make non-digits darker
-                dest[offset + 3] = alpha * (red > 150 ? 1 : 0.7);
-            }
-        }
+    destroy() {
+        this.texture.destroy();
+        this.texture = null;
     }
 
     render(render2d, word, x, y) {
-        const p = this.placements[this.wordMap[word]];
+        const p = this.placements.get(word);
         if (p) {
             const padding = 1;
             render2d.quad(x + p.l - padding,
@@ -96,9 +97,10 @@ class WordAtlas {
                           p.w + padding * 2,
                           p.h + padding * 2,
                           p.x - padding,
-                          64 - p.y - p.h - padding,
+                          this.texture.height - p.y - p.h - padding,
                           undefined, undefined,
-                          true);
+                          this.texture,
+                          1);
             return p.w;
         }
         return 0;
diff --git a/package-lock.json b/package-lock.json
index e1793e825..2d726d360 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,23 +9,23 @@
       "version": "1.66.0-dev",
       "license": "MIT",
       "devDependencies": {
-        "@babel/core": "^7.22.11",
-        "@babel/eslint-parser": "^7.22.11",
-        "@babel/preset-env": "^7.22.10",
+        "@babel/core": "^7.23.0",
+        "@babel/eslint-parser": "^7.22.15",
+        "@babel/preset-env": "^7.22.20",
         "@playcanvas/canvas-mock": "^1.0.1",
-        "@playcanvas/eslint-config": "^1.7.0",
+        "@playcanvas/eslint-config": "^1.7.1",
         "@playcanvas/jsdoc-template": "^1.1.2",
         "@rollup/plugin-babel": "^6.0.3",
         "@rollup/plugin-node-resolve": "^15.2.1",
         "@rollup/plugin-strip": "^3.0.2",
         "@rollup/plugin-terser": "^0.4.3",
         "@rollup/pluginutils": "^5.0.4",
-        "@types/webxr": "^0.5.4",
-        "@webgpu/types": "^0.1.34",
+        "@types/webxr": "^0.5.5",
+        "@webgpu/types": "^0.1.35",
         "c8": "^8.0.0",
-        "chai": "^4.3.8",
-        "eslint": "^8.48.0",
-        "fflate": "^0.8.0",
+        "chai": "^4.3.10",
+        "eslint": "^8.50.0",
+        "fflate": "^0.8.1",
         "jsdoc": "^4.0.2",
         "jsdoc-tsimport-plugin": "^1.0.5",
         "karma": "^6.4.2",
@@ -33,13 +33,13 @@
         "karma-mocha": "2.0.1",
         "karma-spec-reporter": "^0.0.36",
         "mocha": "^10.2.0",
-        "rollup": "^3.28.1",
-        "rollup-plugin-dts": "^6.0.0",
+        "rollup": "^3.29.4",
+        "rollup-plugin-dts": "^6.0.2",
         "rollup-plugin-jscc": "2.0.0",
         "rollup-plugin-visualizer": "^5.9.2",
         "serve": "^14.2.1",
-        "sinon": "^15.2.0",
-        "typedoc": "^0.25.0",
+        "sinon": "^16.0.0",
+        "typedoc": "^0.25.1",
         "typedoc-plugin-mdn-links": "^3.1.0",
         "typescript": "^5.2.2",
         "xhr2": "^0.2.1"
@@ -68,12 +68,12 @@
       }
     },
     "node_modules/@babel/code-frame": {
-      "version": "7.22.10",
-      "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.10.tgz",
-      "integrity": "sha512-/KKIMG4UEL35WmI9OlvMhurwtytjvXoFcGNrOvyG9zIzA8YmPjVtIZUf7b05+TPO7G7/GEmLHDaoCgACHl9hhA==",
+      "version": "7.22.13",
+      "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz",
+      "integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==",
       "dev": true,
       "dependencies": {
-        "@babel/highlight": "^7.22.10",
+        "@babel/highlight": "^7.22.13",
         "chalk": "^2.4.2"
       },
       "engines": {
@@ -81,31 +81,31 @@
       }
     },
     "node_modules/@babel/compat-data": {
-      "version": "7.22.9",
-      "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.22.9.tgz",
-      "integrity": "sha512-5UamI7xkUcJ3i9qVDS+KFDEK8/7oJ55/sJMB1Ge7IEapr7KfdfV/HErR+koZwOfd+SgtFKOKRhRakdg++DcJpQ==",
+      "version": "7.22.20",
+      "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.22.20.tgz",
+      "integrity": "sha512-BQYjKbpXjoXwFW5jGqiizJQQT/aC7pFm9Ok1OWssonuguICi264lbgMzRp2ZMmRSlfkX6DsWDDcsrctK8Rwfiw==",
       "dev": true,
       "engines": {
         "node": ">=6.9.0"
       }
     },
     "node_modules/@babel/core": {
-      "version": "7.22.11",
-      "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.22.11.tgz",
-      "integrity": "sha512-lh7RJrtPdhibbxndr6/xx0w8+CVlY5FJZiaSz908Fpy+G0xkBFTvwLcKJFF4PJxVfGhVWNebikpWGnOoC71juQ==",
+      "version": "7.23.0",
+      "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.0.tgz",
+      "integrity": "sha512-97z/ju/Jy1rZmDxybphrBuI+jtJjFVoz7Mr9yUQVVVi+DNZE333uFQeMOqcCIy1x3WYBIbWftUSLmbNXNT7qFQ==",
       "dev": true,
       "dependencies": {
         "@ampproject/remapping": "^2.2.0",
-        "@babel/code-frame": "^7.22.10",
-        "@babel/generator": "^7.22.10",
-        "@babel/helper-compilation-targets": "^7.22.10",
-        "@babel/helper-module-transforms": "^7.22.9",
-        "@babel/helpers": "^7.22.11",
-        "@babel/parser": "^7.22.11",
-        "@babel/template": "^7.22.5",
-        "@babel/traverse": "^7.22.11",
-        "@babel/types": "^7.22.11",
-        "convert-source-map": "^1.7.0",
+        "@babel/code-frame": "^7.22.13",
+        "@babel/generator": "^7.23.0",
+        "@babel/helper-compilation-targets": "^7.22.15",
+        "@babel/helper-module-transforms": "^7.23.0",
+        "@babel/helpers": "^7.23.0",
+        "@babel/parser": "^7.23.0",
+        "@babel/template": "^7.22.15",
+        "@babel/traverse": "^7.23.0",
+        "@babel/types": "^7.23.0",
+        "convert-source-map": "^2.0.0",
         "debug": "^4.1.0",
         "gensync": "^1.0.0-beta.2",
         "json5": "^2.2.3",
@@ -119,10 +119,16 @@
         "url": "https://opencollective.com/babel"
       }
     },
+    "node_modules/@babel/core/node_modules/convert-source-map": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+      "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+      "dev": true
+    },
     "node_modules/@babel/eslint-parser": {
-      "version": "7.22.11",
-      "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.22.11.tgz",
-      "integrity": "sha512-YjOYZ3j7TjV8OhLW6NCtyg8G04uStATEUe5eiLuCZaXz2VSDQ3dsAtm2D+TuQyAqNMUK2WacGo0/uma9Pein1w==",
+      "version": "7.22.15",
+      "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.22.15.tgz",
+      "integrity": "sha512-yc8OOBIQk1EcRrpizuARSQS0TWAcOMpEJ1aafhNznaeYkeL+OhqnDObGFylB8ka8VFF/sZc+S4RzHyO+3LjQxg==",
       "dev": true,
       "dependencies": {
         "@nicolo-ribaudo/eslint-scope-5-internals": "5.1.1-v1",
@@ -138,12 +144,12 @@
       }
     },
     "node_modules/@babel/generator": {
-      "version": "7.22.10",
-      "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.22.10.tgz",
-      "integrity": "sha512-79KIf7YiWjjdZ81JnLujDRApWtl7BxTqWD88+FFdQEIOG8LJ0etDOM7CXuIgGJa55sGOwZVwuEsaLEm0PJ5/+A==",
+      "version": "7.23.0",
+      "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.0.tgz",
+      "integrity": "sha512-lN85QRR+5IbYrMWM6Y4pE/noaQtg4pNiqeNGX60eqOfo6gtEj6uw/JagelB8vVztSd7R6M5n1+PQkDbHbBRU4g==",
       "dev": true,
       "dependencies": {
-        "@babel/types": "^7.22.10",
+        "@babel/types": "^7.23.0",
         "@jridgewell/gen-mapping": "^0.3.2",
         "@jridgewell/trace-mapping": "^0.3.17",
         "jsesc": "^2.5.1"
@@ -177,13 +183,13 @@
       }
     },
     "node_modules/@babel/helper-compilation-targets": {
-      "version": "7.22.10",
-      "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.10.tgz",
-      "integrity": "sha512-JMSwHD4J7SLod0idLq5PKgI+6g/hLD/iuWBq08ZX49xE14VpVEojJ5rHWptpirV2j020MvypRLAXAO50igCJ5Q==",
+      "version": "7.22.15",
+      "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.15.tgz",
+      "integrity": "sha512-y6EEzULok0Qvz8yyLkCvVX+02ic+By2UdOhylwUOvOn9dvYc9mKICJuuU1n1XBI02YWsNsnrY1kc6DVbjcXbtw==",
       "dev": true,
       "dependencies": {
         "@babel/compat-data": "^7.22.9",
-        "@babel/helper-validator-option": "^7.22.5",
+        "@babel/helper-validator-option": "^7.22.15",
         "browserslist": "^4.21.9",
         "lru-cache": "^5.1.1",
         "semver": "^6.3.1"
@@ -193,15 +199,15 @@
       }
     },
     "node_modules/@babel/helper-create-class-features-plugin": {
-      "version": "7.22.9",
-      "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.22.9.tgz",
-      "integrity": "sha512-Pwyi89uO4YrGKxL/eNJ8lfEH55DnRloGPOseaA8NFNL6jAUnn+KccaISiFazCj5IolPPDjGSdzQzXVzODVRqUQ==",
+      "version": "7.22.15",
+      "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.22.15.tgz",
+      "integrity": "sha512-jKkwA59IXcvSaiK2UN45kKwSC9o+KuoXsBDvHvU/7BecYIp8GQ2UwrVvFgJASUT+hBnwJx6MhvMCuMzwZZ7jlg==",
       "dev": true,
       "dependencies": {
         "@babel/helper-annotate-as-pure": "^7.22.5",
         "@babel/helper-environment-visitor": "^7.22.5",
         "@babel/helper-function-name": "^7.22.5",
-        "@babel/helper-member-expression-to-functions": "^7.22.5",
+        "@babel/helper-member-expression-to-functions": "^7.22.15",
         "@babel/helper-optimise-call-expression": "^7.22.5",
         "@babel/helper-replace-supers": "^7.22.9",
         "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5",
@@ -249,22 +255,22 @@
       }
     },
     "node_modules/@babel/helper-environment-visitor": {
-      "version": "7.22.5",
-      "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.5.tgz",
-      "integrity": "sha512-XGmhECfVA/5sAt+H+xpSg0mfrHq6FzNr9Oxh7PSEBBRUb/mL7Kz3NICXb194rCqAEdxkhPT1a88teizAFyvk8Q==",
+      "version": "7.22.20",
+      "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz",
+      "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==",
       "dev": true,
       "engines": {
         "node": ">=6.9.0"
       }
     },
     "node_modules/@babel/helper-function-name": {
-      "version": "7.22.5",
-      "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.22.5.tgz",
-      "integrity": "sha512-wtHSq6jMRE3uF2otvfuD3DIvVhOsSNshQl0Qrd7qC9oQJzHvOL4qQXlQn2916+CXGywIjpGuIkoyZRRxHPiNQQ==",
+      "version": "7.23.0",
+      "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz",
+      "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==",
       "dev": true,
       "dependencies": {
-        "@babel/template": "^7.22.5",
-        "@babel/types": "^7.22.5"
+        "@babel/template": "^7.22.15",
+        "@babel/types": "^7.23.0"
       },
       "engines": {
         "node": ">=6.9.0"
@@ -283,40 +289,40 @@
       }
     },
     "node_modules/@babel/helper-member-expression-to-functions": {
-      "version": "7.22.5",
-      "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.22.5.tgz",
-      "integrity": "sha512-aBiH1NKMG0H2cGZqspNvsaBe6wNGjbJjuLy29aU+eDZjSbbN53BaxlpB02xm9v34pLTZ1nIQPFYn2qMZoa5BQQ==",
+      "version": "7.23.0",
+      "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.23.0.tgz",
+      "integrity": "sha512-6gfrPwh7OuT6gZyJZvd6WbTfrqAo7vm4xCzAXOusKqq/vWdKXphTpj5klHKNmRUU6/QRGlBsyU9mAIPaWHlqJA==",
       "dev": true,
       "dependencies": {
-        "@babel/types": "^7.22.5"
+        "@babel/types": "^7.23.0"
       },
       "engines": {
         "node": ">=6.9.0"
       }
     },
     "node_modules/@babel/helper-module-imports": {
-      "version": "7.22.5",
-      "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.5.tgz",
-      "integrity": "sha512-8Dl6+HD/cKifutF5qGd/8ZJi84QeAKh+CEe1sBzz8UayBBGg1dAIJrdHOcOM5b2MpzWL2yuotJTtGjETq0qjXg==",
+      "version": "7.22.15",
+      "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz",
+      "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==",
       "dev": true,
       "dependencies": {
-        "@babel/types": "^7.22.5"
+        "@babel/types": "^7.22.15"
       },
       "engines": {
         "node": ">=6.9.0"
       }
     },
     "node_modules/@babel/helper-module-transforms": {
-      "version": "7.22.9",
-      "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.22.9.tgz",
-      "integrity": "sha512-t+WA2Xn5K+rTeGtC8jCsdAH52bjggG5TKRuRrAGNM/mjIbO4GxvlLMFOEz9wXY5I2XQ60PMFsAG2WIcG82dQMQ==",
+      "version": "7.23.0",
+      "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.0.tgz",
+      "integrity": "sha512-WhDWw1tdrlT0gMgUJSlX0IQvoO1eN279zrAUbVB+KpV2c3Tylz8+GnKOLllCS6Z/iZQEyVYxhZVUdPTqs2YYPw==",
       "dev": true,
       "dependencies": {
-        "@babel/helper-environment-visitor": "^7.22.5",
-        "@babel/helper-module-imports": "^7.22.5",
+        "@babel/helper-environment-visitor": "^7.22.20",
+        "@babel/helper-module-imports": "^7.22.15",
         "@babel/helper-simple-access": "^7.22.5",
         "@babel/helper-split-export-declaration": "^7.22.6",
-        "@babel/helper-validator-identifier": "^7.22.5"
+        "@babel/helper-validator-identifier": "^7.22.20"
       },
       "engines": {
         "node": ">=6.9.0"
@@ -426,18 +432,18 @@
       }
     },
     "node_modules/@babel/helper-validator-identifier": {
-      "version": "7.22.5",
-      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.5.tgz",
-      "integrity": "sha512-aJXu+6lErq8ltp+JhkJUfk1MTGyuA4v7f3pA+BJ5HLfNC6nAQ0Cpi9uOquUj8Hehg0aUiHzWQbOVJGao6ztBAQ==",
+      "version": "7.22.20",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz",
+      "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==",
       "dev": true,
       "engines": {
         "node": ">=6.9.0"
       }
     },
     "node_modules/@babel/helper-validator-option": {
-      "version": "7.22.5",
-      "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.22.5.tgz",
-      "integrity": "sha512-R3oB6xlIVKUnxNUxbmgq7pKjxpru24zlimpE8WK47fACIlM0II/Hm1RS8IaOI7NgCr6LNS+jl5l75m20npAziw==",
+      "version": "7.22.15",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.22.15.tgz",
+      "integrity": "sha512-bMn7RmyFjY/mdECUbgn9eoSY4vqvacUnS9i9vGAGttgFWesO6B4CYWA7XlpbWgBt71iv/hfbPlynohStqnu5hA==",
       "dev": true,
       "engines": {
         "node": ">=6.9.0"
@@ -458,26 +464,26 @@
       }
     },
     "node_modules/@babel/helpers": {
-      "version": "7.22.11",
-      "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.22.11.tgz",
-      "integrity": "sha512-vyOXC8PBWaGc5h7GMsNx68OH33cypkEDJCHvYVVgVbbxJDROYVtexSk0gK5iCF1xNjRIN2s8ai7hwkWDq5szWg==",
+      "version": "7.23.1",
+      "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.23.1.tgz",
+      "integrity": "sha512-chNpneuK18yW5Oxsr+t553UZzzAs3aZnFm4bxhebsNTeshrC95yA7l5yl7GBAG+JG1rF0F7zzD2EixK9mWSDoA==",
       "dev": true,
       "dependencies": {
-        "@babel/template": "^7.22.5",
-        "@babel/traverse": "^7.22.11",
-        "@babel/types": "^7.22.11"
+        "@babel/template": "^7.22.15",
+        "@babel/traverse": "^7.23.0",
+        "@babel/types": "^7.23.0"
       },
       "engines": {
         "node": ">=6.9.0"
       }
     },
     "node_modules/@babel/highlight": {
-      "version": "7.22.10",
-      "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.10.tgz",
-      "integrity": "sha512-78aUtVcT7MUscr0K5mIEnkwxPE0MaxkR5RxRwuHaQ+JuU5AmTPhY+do2mdzVTnIJJpyBglql2pehuBIWHug+WQ==",
+      "version": "7.22.20",
+      "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.20.tgz",
+      "integrity": "sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==",
       "dev": true,
       "dependencies": {
-        "@babel/helper-validator-identifier": "^7.22.5",
+        "@babel/helper-validator-identifier": "^7.22.20",
         "chalk": "^2.4.2",
         "js-tokens": "^4.0.0"
       },
@@ -486,9 +492,9 @@
       }
     },
     "node_modules/@babel/parser": {
-      "version": "7.22.13",
-      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.22.13.tgz",
-      "integrity": "sha512-3l6+4YOvc9wx7VlCSw4yQfcBo01ECA8TicQfbnCPuCEpRQrf+gTUyGdxNw+pyTUyywp6JRD1w0YQs9TpBXYlkw==",
+      "version": "7.23.0",
+      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.0.tgz",
+      "integrity": "sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==",
       "dev": true,
       "bin": {
         "parser": "bin/babel-parser.js"
@@ -498,9 +504,9 @@
       }
     },
     "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": {
-      "version": "7.22.5",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.22.5.tgz",
-      "integrity": "sha512-NP1M5Rf+u2Gw9qfSO4ihjcTGW5zXTi36ITLd4/EoAcEhIZ0yjMqmftDNl3QC19CX7olhrjpyU454g/2W7X0jvQ==",
+      "version": "7.22.15",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.22.15.tgz",
+      "integrity": "sha512-FB9iYlz7rURmRJyXRKEnalYPPdn87H5no108cyuQQyMwlpJ2SJtpIUBI27kdTin956pz+LPypkPVPUTlxOmrsg==",
       "dev": true,
       "dependencies": {
         "@babel/helper-plugin-utils": "^7.22.5"
@@ -513,14 +519,14 @@
       }
     },
     "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": {
-      "version": "7.22.5",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.22.5.tgz",
-      "integrity": "sha512-31Bb65aZaUwqCbWMnZPduIZxCBngHFlzyN6Dq6KAJjtx+lx6ohKHubc61OomYi7XwVD4Ol0XCVz4h+pYFR048g==",
+      "version": "7.22.15",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.22.15.tgz",
+      "integrity": "sha512-Hyph9LseGvAeeXzikV88bczhsrLrIZqDPxO+sSmAunMPaGrBGhfMWzCPYTtiW9t+HzSE2wtV8e5cc5P6r1xMDQ==",
       "dev": true,
       "dependencies": {
         "@babel/helper-plugin-utils": "^7.22.5",
         "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5",
-        "@babel/plugin-transform-optional-chaining": "^7.22.5"
+        "@babel/plugin-transform-optional-chaining": "^7.22.15"
       },
       "engines": {
         "node": ">=6.9.0"
@@ -792,9 +798,9 @@
       }
     },
     "node_modules/@babel/plugin-transform-async-generator-functions": {
-      "version": "7.22.10",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.22.10.tgz",
-      "integrity": "sha512-eueE8lvKVzq5wIObKK/7dvoeKJ+xc6TvRn6aysIjS6pSCeLy7S/eVi7pEQknZqyqvzaNKdDtem8nUNTBgDVR2g==",
+      "version": "7.22.15",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.22.15.tgz",
+      "integrity": "sha512-jBm1Es25Y+tVoTi5rfd5t1KLmL8ogLKpXszboWOTTtGFGz2RKnQe2yn7HbZ+kb/B8N0FVSGQo874NSlOU1T4+w==",
       "dev": true,
       "dependencies": {
         "@babel/helper-environment-visitor": "^7.22.5",
@@ -842,9 +848,9 @@
       }
     },
     "node_modules/@babel/plugin-transform-block-scoping": {
-      "version": "7.22.10",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.22.10.tgz",
-      "integrity": "sha512-1+kVpGAOOI1Albt6Vse7c8pHzcZQdQKW+wJH+g8mCaszOdDVwRXa/slHPqIw+oJAJANTKDMuM2cBdV0Dg618Vg==",
+      "version": "7.23.0",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.23.0.tgz",
+      "integrity": "sha512-cOsrbmIOXmf+5YbL99/S49Y3j46k/T16b9ml8bm9lP6N9US5iQ2yBK7gpui1pg0V/WMcXdkfKbTb7HXq9u+v4g==",
       "dev": true,
       "dependencies": {
         "@babel/helper-plugin-utils": "^7.22.5"
@@ -873,12 +879,12 @@
       }
     },
     "node_modules/@babel/plugin-transform-class-static-block": {
-      "version": "7.22.5",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.22.5.tgz",
-      "integrity": "sha512-SPToJ5eYZLxlnp1UzdARpOGeC2GbHvr9d/UV0EukuVx8atktg194oe+C5BqQ8jRTkgLRVOPYeXRSBg1IlMoVRA==",
+      "version": "7.22.11",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.22.11.tgz",
+      "integrity": "sha512-GMM8gGmqI7guS/llMFk1bJDkKfn3v3C4KHK9Yg1ey5qcHcOlKb0QvcMrgzvxo+T03/4szNh5lghY+fEC98Kq9g==",
       "dev": true,
       "dependencies": {
-        "@babel/helper-create-class-features-plugin": "^7.22.5",
+        "@babel/helper-create-class-features-plugin": "^7.22.11",
         "@babel/helper-plugin-utils": "^7.22.5",
         "@babel/plugin-syntax-class-static-block": "^7.14.5"
       },
@@ -890,18 +896,18 @@
       }
     },
     "node_modules/@babel/plugin-transform-classes": {
-      "version": "7.22.6",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.22.6.tgz",
-      "integrity": "sha512-58EgM6nuPNG6Py4Z3zSuu0xWu2VfodiMi72Jt5Kj2FECmaYk1RrTXA45z6KBFsu9tRgwQDwIiY4FXTt+YsSFAQ==",
+      "version": "7.22.15",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.22.15.tgz",
+      "integrity": "sha512-VbbC3PGjBdE0wAWDdHM9G8Gm977pnYI0XpqMd6LrKISj8/DJXEsWqgRuTYaNE9Bv0JGhTZUzHDlMk18IpOuoqw==",
       "dev": true,
       "dependencies": {
         "@babel/helper-annotate-as-pure": "^7.22.5",
-        "@babel/helper-compilation-targets": "^7.22.6",
+        "@babel/helper-compilation-targets": "^7.22.15",
         "@babel/helper-environment-visitor": "^7.22.5",
         "@babel/helper-function-name": "^7.22.5",
         "@babel/helper-optimise-call-expression": "^7.22.5",
         "@babel/helper-plugin-utils": "^7.22.5",
-        "@babel/helper-replace-supers": "^7.22.5",
+        "@babel/helper-replace-supers": "^7.22.9",
         "@babel/helper-split-export-declaration": "^7.22.6",
         "globals": "^11.1.0"
       },
@@ -929,9 +935,9 @@
       }
     },
     "node_modules/@babel/plugin-transform-destructuring": {
-      "version": "7.22.10",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.22.10.tgz",
-      "integrity": "sha512-dPJrL0VOyxqLM9sritNbMSGx/teueHF/htMKrPT7DNxccXxRDPYqlgPFFdr8u+F+qUZOkZoXue/6rL5O5GduEw==",
+      "version": "7.23.0",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.23.0.tgz",
+      "integrity": "sha512-vaMdgNXFkYrB+8lbgniSYWHsgqK5gjaMNcc84bMIOMRLH0L9AqYq3hwMdvnyqj1OPqea8UtjPEuS/DCenah1wg==",
       "dev": true,
       "dependencies": {
         "@babel/helper-plugin-utils": "^7.22.5"
@@ -975,9 +981,9 @@
       }
     },
     "node_modules/@babel/plugin-transform-dynamic-import": {
-      "version": "7.22.5",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.22.5.tgz",
-      "integrity": "sha512-0MC3ppTB1AMxd8fXjSrbPa7LT9hrImt+/fcj+Pg5YMD7UQyWp/02+JWpdnCymmsXwIx5Z+sYn1bwCn4ZJNvhqQ==",
+      "version": "7.22.11",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.22.11.tgz",
+      "integrity": "sha512-g/21plo58sfteWjaO0ZNVb+uEOkJNjAaHhbejrnBmu011l/eNDScmkbjCC3l4FKb10ViaGU4aOkFznSu2zRHgA==",
       "dev": true,
       "dependencies": {
         "@babel/helper-plugin-utils": "^7.22.5",
@@ -1007,9 +1013,9 @@
       }
     },
     "node_modules/@babel/plugin-transform-export-namespace-from": {
-      "version": "7.22.5",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.22.5.tgz",
-      "integrity": "sha512-X4hhm7FRnPgd4nDA4b/5V280xCx6oL7Oob5+9qVS5C13Zq4bh1qq7LU0GgRU6b5dBWBvhGaXYVB4AcN6+ol6vg==",
+      "version": "7.22.11",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.22.11.tgz",
+      "integrity": "sha512-xa7aad7q7OiT8oNZ1mU7NrISjlSkVdMbNxn9IuLZyL9AJEhs1Apba3I+u5riX1dIkdptP5EKDG5XDPByWxtehw==",
       "dev": true,
       "dependencies": {
         "@babel/helper-plugin-utils": "^7.22.5",
@@ -1023,9 +1029,9 @@
       }
     },
     "node_modules/@babel/plugin-transform-for-of": {
-      "version": "7.22.5",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.22.5.tgz",
-      "integrity": "sha512-3kxQjX1dU9uudwSshyLeEipvrLjBCVthCgeTp6CzE/9JYrlAIaeekVxRpCWsDDfYTfRZRoCeZatCQvwo+wvK8A==",
+      "version": "7.22.15",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.22.15.tgz",
+      "integrity": "sha512-me6VGeHsx30+xh9fbDLLPi0J1HzmeIIyenoOQHuw2D4m2SAU3NrspX5XxJLBpqn5yrLzrlw2Iy3RA//Bx27iOA==",
       "dev": true,
       "dependencies": {
         "@babel/helper-plugin-utils": "^7.22.5"
@@ -1055,9 +1061,9 @@
       }
     },
     "node_modules/@babel/plugin-transform-json-strings": {
-      "version": "7.22.5",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.22.5.tgz",
-      "integrity": "sha512-DuCRB7fu8MyTLbEQd1ew3R85nx/88yMoqo2uPSjevMj3yoN7CDM8jkgrY0wmVxfJZyJ/B9fE1iq7EQppWQmR5A==",
+      "version": "7.22.11",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.22.11.tgz",
+      "integrity": "sha512-CxT5tCqpA9/jXFlme9xIBCc5RPtdDq3JpkkhgHQqtDdiTnTI0jtZ0QzXhr5DILeYifDPp2wvY2ad+7+hLMW5Pw==",
       "dev": true,
       "dependencies": {
         "@babel/helper-plugin-utils": "^7.22.5",
@@ -1086,9 +1092,9 @@
       }
     },
     "node_modules/@babel/plugin-transform-logical-assignment-operators": {
-      "version": "7.22.5",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.22.5.tgz",
-      "integrity": "sha512-MQQOUW1KL8X0cDWfbwYP+TbVbZm16QmQXJQ+vndPtH/BoO0lOKpVoEDMI7+PskYxH+IiE0tS8xZye0qr1lGzSA==",
+      "version": "7.22.11",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.22.11.tgz",
+      "integrity": "sha512-qQwRTP4+6xFCDV5k7gZBF3C31K34ut0tbEcTKxlX/0KXxm9GLcO14p570aWxFvVzx6QAfPgq7gaeIHXJC8LswQ==",
       "dev": true,
       "dependencies": {
         "@babel/helper-plugin-utils": "^7.22.5",
@@ -1133,12 +1139,12 @@
       }
     },
     "node_modules/@babel/plugin-transform-modules-commonjs": {
-      "version": "7.22.5",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.22.5.tgz",
-      "integrity": "sha512-B4pzOXj+ONRmuaQTg05b3y/4DuFz3WcCNAXPLb2Q0GT0TrGKGxNKV4jwsXts+StaM0LQczZbOpj8o1DLPDJIiA==",
+      "version": "7.23.0",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.23.0.tgz",
+      "integrity": "sha512-32Xzss14/UVc7k9g775yMIvkVK8xwKE0DPdP5JTapr3+Z9w4tzeOuLNY6BXDQR6BdnzIlXnCGAzsk/ICHBLVWQ==",
       "dev": true,
       "dependencies": {
-        "@babel/helper-module-transforms": "^7.22.5",
+        "@babel/helper-module-transforms": "^7.23.0",
         "@babel/helper-plugin-utils": "^7.22.5",
         "@babel/helper-simple-access": "^7.22.5"
       },
@@ -1150,15 +1156,15 @@
       }
     },
     "node_modules/@babel/plugin-transform-modules-systemjs": {
-      "version": "7.22.5",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.22.5.tgz",
-      "integrity": "sha512-emtEpoaTMsOs6Tzz+nbmcePl6AKVtS1yC4YNAeMun9U8YCsgadPNxnOPQ8GhHFB2qdx+LZu9LgoC0Lthuu05DQ==",
+      "version": "7.23.0",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.23.0.tgz",
+      "integrity": "sha512-qBej6ctXZD2f+DhlOC9yO47yEYgUh5CZNz/aBoH4j/3NOlRfJXJbY7xDQCqQVf9KbrqGzIWER1f23doHGrIHFg==",
       "dev": true,
       "dependencies": {
         "@babel/helper-hoist-variables": "^7.22.5",
-        "@babel/helper-module-transforms": "^7.22.5",
+        "@babel/helper-module-transforms": "^7.23.0",
         "@babel/helper-plugin-utils": "^7.22.5",
-        "@babel/helper-validator-identifier": "^7.22.5"
+        "@babel/helper-validator-identifier": "^7.22.20"
       },
       "engines": {
         "node": ">=6.9.0"
@@ -1215,9 +1221,9 @@
       }
     },
     "node_modules/@babel/plugin-transform-nullish-coalescing-operator": {
-      "version": "7.22.5",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.22.5.tgz",
-      "integrity": "sha512-6CF8g6z1dNYZ/VXok5uYkkBBICHZPiGEl7oDnAx2Mt1hlHVHOSIKWJaXHjQJA5VB43KZnXZDIexMchY4y2PGdA==",
+      "version": "7.22.11",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.22.11.tgz",
+      "integrity": "sha512-YZWOw4HxXrotb5xsjMJUDlLgcDXSfO9eCmdl1bgW4+/lAGdkjaEvOnQ4p5WKKdUgSzO39dgPl0pTnfxm0OAXcg==",
       "dev": true,
       "dependencies": {
         "@babel/helper-plugin-utils": "^7.22.5",
@@ -1231,9 +1237,9 @@
       }
     },
     "node_modules/@babel/plugin-transform-numeric-separator": {
-      "version": "7.22.5",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.22.5.tgz",
-      "integrity": "sha512-NbslED1/6M+sXiwwtcAB/nieypGw02Ejf4KtDeMkCEpP6gWFMX1wI9WKYua+4oBneCCEmulOkRpwywypVZzs/g==",
+      "version": "7.22.11",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.22.11.tgz",
+      "integrity": "sha512-3dzU4QGPsILdJbASKhF/V2TVP+gJya1PsueQCxIPCEcerqF21oEcrob4mzjsp2Py/1nLfF5m+xYNMDpmA8vffg==",
       "dev": true,
       "dependencies": {
         "@babel/helper-plugin-utils": "^7.22.5",
@@ -1247,16 +1253,16 @@
       }
     },
     "node_modules/@babel/plugin-transform-object-rest-spread": {
-      "version": "7.22.5",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.22.5.tgz",
-      "integrity": "sha512-Kk3lyDmEslH9DnvCDA1s1kkd3YWQITiBOHngOtDL9Pt6BZjzqb6hiOlb8VfjiiQJ2unmegBqZu0rx5RxJb5vmQ==",
+      "version": "7.22.15",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.22.15.tgz",
+      "integrity": "sha512-fEB+I1+gAmfAyxZcX1+ZUwLeAuuf8VIg67CTznZE0MqVFumWkh8xWtn58I4dxdVf080wn7gzWoF8vndOViJe9Q==",
       "dev": true,
       "dependencies": {
-        "@babel/compat-data": "^7.22.5",
-        "@babel/helper-compilation-targets": "^7.22.5",
+        "@babel/compat-data": "^7.22.9",
+        "@babel/helper-compilation-targets": "^7.22.15",
         "@babel/helper-plugin-utils": "^7.22.5",
         "@babel/plugin-syntax-object-rest-spread": "^7.8.3",
-        "@babel/plugin-transform-parameters": "^7.22.5"
+        "@babel/plugin-transform-parameters": "^7.22.15"
       },
       "engines": {
         "node": ">=6.9.0"
@@ -1282,9 +1288,9 @@
       }
     },
     "node_modules/@babel/plugin-transform-optional-catch-binding": {
-      "version": "7.22.5",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.22.5.tgz",
-      "integrity": "sha512-pH8orJahy+hzZje5b8e2QIlBWQvGpelS76C63Z+jhZKsmzfNaPQ+LaW6dcJ9bxTpo1mtXbgHwy765Ro3jftmUg==",
+      "version": "7.22.11",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.22.11.tgz",
+      "integrity": "sha512-rli0WxesXUeCJnMYhzAglEjLWVDF6ahb45HuprcmQuLidBJFWjNnOzssk2kuc6e33FlLaiZhG/kUIzUMWdBKaQ==",
       "dev": true,
       "dependencies": {
         "@babel/helper-plugin-utils": "^7.22.5",
@@ -1298,9 +1304,9 @@
       }
     },
     "node_modules/@babel/plugin-transform-optional-chaining": {
-      "version": "7.22.10",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.22.10.tgz",
-      "integrity": "sha512-MMkQqZAZ+MGj+jGTG3OTuhKeBpNcO+0oCEbrGNEaOmiEn+1MzRyQlYsruGiU8RTK3zV6XwrVJTmwiDOyYK6J9g==",
+      "version": "7.23.0",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.23.0.tgz",
+      "integrity": "sha512-sBBGXbLJjxTzLBF5rFWaikMnOGOk/BmK6vVByIdEggZ7Vn6CvWXZyRkkLFK6WE0IF8jSliyOkUN6SScFgzCM0g==",
       "dev": true,
       "dependencies": {
         "@babel/helper-plugin-utils": "^7.22.5",
@@ -1315,9 +1321,9 @@
       }
     },
     "node_modules/@babel/plugin-transform-parameters": {
-      "version": "7.22.5",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.22.5.tgz",
-      "integrity": "sha512-AVkFUBurORBREOmHRKo06FjHYgjrabpdqRSwq6+C7R5iTCZOsM4QbcB27St0a4U6fffyAOqh3s/qEfybAhfivg==",
+      "version": "7.22.15",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.22.15.tgz",
+      "integrity": "sha512-hjk7qKIqhyzhhUvRT683TYQOFa/4cQKwQy7ALvTpODswN40MljzNDa0YldevS6tGbxwaEKVn502JmY0dP7qEtQ==",
       "dev": true,
       "dependencies": {
         "@babel/helper-plugin-utils": "^7.22.5"
@@ -1346,13 +1352,13 @@
       }
     },
     "node_modules/@babel/plugin-transform-private-property-in-object": {
-      "version": "7.22.5",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.22.5.tgz",
-      "integrity": "sha512-/9xnaTTJcVoBtSSmrVyhtSvO3kbqS2ODoh2juEU72c3aYonNF0OMGiaz2gjukyKM2wBBYJP38S4JiE0Wfb5VMQ==",
+      "version": "7.22.11",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.22.11.tgz",
+      "integrity": "sha512-sSCbqZDBKHetvjSwpyWzhuHkmW5RummxJBVbYLkGkaiTOWGxml7SXt0iWa03bzxFIx7wOj3g/ILRd0RcJKBeSQ==",
       "dev": true,
       "dependencies": {
         "@babel/helper-annotate-as-pure": "^7.22.5",
-        "@babel/helper-create-class-features-plugin": "^7.22.5",
+        "@babel/helper-create-class-features-plugin": "^7.22.11",
         "@babel/helper-plugin-utils": "^7.22.5",
         "@babel/plugin-syntax-private-property-in-object": "^7.14.5"
       },
@@ -1549,17 +1555,17 @@
       }
     },
     "node_modules/@babel/preset-env": {
-      "version": "7.22.10",
-      "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.22.10.tgz",
-      "integrity": "sha512-riHpLb1drNkpLlocmSyEg4oYJIQFeXAK/d7rI6mbD0XsvoTOOweXDmQPG/ErxsEhWk3rl3Q/3F6RFQlVFS8m0A==",
+      "version": "7.22.20",
+      "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.22.20.tgz",
+      "integrity": "sha512-11MY04gGC4kSzlPHRfvVkNAZhUxOvm7DCJ37hPDnUENwe06npjIRAfInEMTGSb4LZK5ZgDFkv5hw0lGebHeTyg==",
       "dev": true,
       "dependencies": {
-        "@babel/compat-data": "^7.22.9",
-        "@babel/helper-compilation-targets": "^7.22.10",
+        "@babel/compat-data": "^7.22.20",
+        "@babel/helper-compilation-targets": "^7.22.15",
         "@babel/helper-plugin-utils": "^7.22.5",
-        "@babel/helper-validator-option": "^7.22.5",
-        "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.22.5",
-        "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.22.5",
+        "@babel/helper-validator-option": "^7.22.15",
+        "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.22.15",
+        "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.22.15",
         "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2",
         "@babel/plugin-syntax-async-generators": "^7.8.4",
         "@babel/plugin-syntax-class-properties": "^7.12.13",
@@ -1580,41 +1586,41 @@
         "@babel/plugin-syntax-top-level-await": "^7.14.5",
         "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6",
         "@babel/plugin-transform-arrow-functions": "^7.22.5",
-        "@babel/plugin-transform-async-generator-functions": "^7.22.10",
+        "@babel/plugin-transform-async-generator-functions": "^7.22.15",
         "@babel/plugin-transform-async-to-generator": "^7.22.5",
         "@babel/plugin-transform-block-scoped-functions": "^7.22.5",
-        "@babel/plugin-transform-block-scoping": "^7.22.10",
+        "@babel/plugin-transform-block-scoping": "^7.22.15",
         "@babel/plugin-transform-class-properties": "^7.22.5",
-        "@babel/plugin-transform-class-static-block": "^7.22.5",
-        "@babel/plugin-transform-classes": "^7.22.6",
+        "@babel/plugin-transform-class-static-block": "^7.22.11",
+        "@babel/plugin-transform-classes": "^7.22.15",
         "@babel/plugin-transform-computed-properties": "^7.22.5",
-        "@babel/plugin-transform-destructuring": "^7.22.10",
+        "@babel/plugin-transform-destructuring": "^7.22.15",
         "@babel/plugin-transform-dotall-regex": "^7.22.5",
         "@babel/plugin-transform-duplicate-keys": "^7.22.5",
-        "@babel/plugin-transform-dynamic-import": "^7.22.5",
+        "@babel/plugin-transform-dynamic-import": "^7.22.11",
         "@babel/plugin-transform-exponentiation-operator": "^7.22.5",
-        "@babel/plugin-transform-export-namespace-from": "^7.22.5",
-        "@babel/plugin-transform-for-of": "^7.22.5",
+        "@babel/plugin-transform-export-namespace-from": "^7.22.11",
+        "@babel/plugin-transform-for-of": "^7.22.15",
         "@babel/plugin-transform-function-name": "^7.22.5",
-        "@babel/plugin-transform-json-strings": "^7.22.5",
+        "@babel/plugin-transform-json-strings": "^7.22.11",
         "@babel/plugin-transform-literals": "^7.22.5",
-        "@babel/plugin-transform-logical-assignment-operators": "^7.22.5",
+        "@babel/plugin-transform-logical-assignment-operators": "^7.22.11",
         "@babel/plugin-transform-member-expression-literals": "^7.22.5",
         "@babel/plugin-transform-modules-amd": "^7.22.5",
-        "@babel/plugin-transform-modules-commonjs": "^7.22.5",
-        "@babel/plugin-transform-modules-systemjs": "^7.22.5",
+        "@babel/plugin-transform-modules-commonjs": "^7.22.15",
+        "@babel/plugin-transform-modules-systemjs": "^7.22.11",
         "@babel/plugin-transform-modules-umd": "^7.22.5",
         "@babel/plugin-transform-named-capturing-groups-regex": "^7.22.5",
         "@babel/plugin-transform-new-target": "^7.22.5",
-        "@babel/plugin-transform-nullish-coalescing-operator": "^7.22.5",
-        "@babel/plugin-transform-numeric-separator": "^7.22.5",
-        "@babel/plugin-transform-object-rest-spread": "^7.22.5",
+        "@babel/plugin-transform-nullish-coalescing-operator": "^7.22.11",
+        "@babel/plugin-transform-numeric-separator": "^7.22.11",
+        "@babel/plugin-transform-object-rest-spread": "^7.22.15",
         "@babel/plugin-transform-object-super": "^7.22.5",
-        "@babel/plugin-transform-optional-catch-binding": "^7.22.5",
-        "@babel/plugin-transform-optional-chaining": "^7.22.10",
-        "@babel/plugin-transform-parameters": "^7.22.5",
+        "@babel/plugin-transform-optional-catch-binding": "^7.22.11",
+        "@babel/plugin-transform-optional-chaining": "^7.22.15",
+        "@babel/plugin-transform-parameters": "^7.22.15",
         "@babel/plugin-transform-private-methods": "^7.22.5",
-        "@babel/plugin-transform-private-property-in-object": "^7.22.5",
+        "@babel/plugin-transform-private-property-in-object": "^7.22.11",
         "@babel/plugin-transform-property-literals": "^7.22.5",
         "@babel/plugin-transform-regenerator": "^7.22.10",
         "@babel/plugin-transform-reserved-words": "^7.22.5",
@@ -1628,7 +1634,7 @@
         "@babel/plugin-transform-unicode-regex": "^7.22.5",
         "@babel/plugin-transform-unicode-sets-regex": "^7.22.5",
         "@babel/preset-modules": "0.1.6-no-external-plugins",
-        "@babel/types": "^7.22.10",
+        "@babel/types": "^7.22.19",
         "babel-plugin-polyfill-corejs2": "^0.4.5",
         "babel-plugin-polyfill-corejs3": "^0.8.3",
         "babel-plugin-polyfill-regenerator": "^0.5.2",
@@ -1675,33 +1681,33 @@
       }
     },
     "node_modules/@babel/template": {
-      "version": "7.22.5",
-      "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.5.tgz",
-      "integrity": "sha512-X7yV7eiwAxdj9k94NEylvbVHLiVG1nvzCV2EAowhxLTwODV1jl9UzZ48leOC0sH7OnuHrIkllaBgneUykIcZaw==",
+      "version": "7.22.15",
+      "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz",
+      "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==",
       "dev": true,
       "dependencies": {
-        "@babel/code-frame": "^7.22.5",
-        "@babel/parser": "^7.22.5",
-        "@babel/types": "^7.22.5"
+        "@babel/code-frame": "^7.22.13",
+        "@babel/parser": "^7.22.15",
+        "@babel/types": "^7.22.15"
       },
       "engines": {
         "node": ">=6.9.0"
       }
     },
     "node_modules/@babel/traverse": {
-      "version": "7.22.11",
-      "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.22.11.tgz",
-      "integrity": "sha512-mzAenteTfomcB7mfPtyi+4oe5BZ6MXxWcn4CX+h4IRJ+OOGXBrWU6jDQavkQI9Vuc5P+donFabBfFCcmWka9lQ==",
+      "version": "7.23.0",
+      "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.0.tgz",
+      "integrity": "sha512-t/QaEvyIoIkwzpiZ7aoSKK8kObQYeF7T2v+dazAYCb8SXtp58zEVkWW7zAnju8FNKNdr4ScAOEDmMItbyOmEYw==",
       "dev": true,
       "dependencies": {
-        "@babel/code-frame": "^7.22.10",
-        "@babel/generator": "^7.22.10",
-        "@babel/helper-environment-visitor": "^7.22.5",
-        "@babel/helper-function-name": "^7.22.5",
+        "@babel/code-frame": "^7.22.13",
+        "@babel/generator": "^7.23.0",
+        "@babel/helper-environment-visitor": "^7.22.20",
+        "@babel/helper-function-name": "^7.23.0",
         "@babel/helper-hoist-variables": "^7.22.5",
         "@babel/helper-split-export-declaration": "^7.22.6",
-        "@babel/parser": "^7.22.11",
-        "@babel/types": "^7.22.11",
+        "@babel/parser": "^7.23.0",
+        "@babel/types": "^7.23.0",
         "debug": "^4.1.0",
         "globals": "^11.1.0"
       },
@@ -1710,13 +1716,13 @@
       }
     },
     "node_modules/@babel/types": {
-      "version": "7.22.11",
-      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.22.11.tgz",
-      "integrity": "sha512-siazHiGuZRz9aB9NpHy9GOs9xiQPKnMzgdr493iI1M67vRXpnEq8ZOOKzezC5q7zwuQ6sDhdSp4SD9ixKSqKZg==",
+      "version": "7.23.0",
+      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz",
+      "integrity": "sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==",
       "dev": true,
       "dependencies": {
         "@babel/helper-string-parser": "^7.22.5",
-        "@babel/helper-validator-identifier": "^7.22.5",
+        "@babel/helper-validator-identifier": "^7.22.20",
         "to-fast-properties": "^2.0.0"
       },
       "engines": {
@@ -1839,18 +1845,18 @@
       }
     },
     "node_modules/@eslint/js": {
-      "version": "8.48.0",
-      "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.48.0.tgz",
-      "integrity": "sha512-ZSjtmelB7IJfWD2Fvb7+Z+ChTIKWq6kjda95fLcQKNS5aheVHn4IkfgRQE3sIIzTcSLwLcLZUD9UBt+V7+h+Pw==",
+      "version": "8.50.0",
+      "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.50.0.tgz",
+      "integrity": "sha512-NCC3zz2+nvYd+Ckfh87rA47zfu2QsQpvc6k1yzTk+b9KzRj0wkGa8LSoGOXN6Zv4lRf/EIoZ80biDh9HOI+RNQ==",
       "dev": true,
       "engines": {
         "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
       }
     },
     "node_modules/@humanwhocodes/config-array": {
-      "version": "0.11.10",
-      "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.10.tgz",
-      "integrity": "sha512-KVVjQmNUepDVGXNuoRRdmmEjruj0KfiGSbS8LVc12LMsWDQzRXJ0qdhN8L8uUigKpfEHRhlaQFY0ib1tnUbNeQ==",
+      "version": "0.11.11",
+      "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.11.tgz",
+      "integrity": "sha512-N2brEuAadi0CcdeMXUkhbZB84eskAc8MEX1By6qEchoVywSgXPIjou4rYsl0V3Hj0ZnuGycGCjdNgockbzeWNA==",
       "dev": true,
       "dependencies": {
         "@humanwhocodes/object-schema": "^1.2.1",
@@ -2034,9 +2040,9 @@
       "dev": true
     },
     "node_modules/@playcanvas/eslint-config": {
-      "version": "1.7.0",
-      "resolved": "https://registry.npmjs.org/@playcanvas/eslint-config/-/eslint-config-1.7.0.tgz",
-      "integrity": "sha512-Ivr2+RFyJr1RPea1SxHS9d1fvf6Zm2H2fBBcotbfbchpw3qIVmiqpEWBZhsrcIapyy8ylzrORhlHE1p2OWTjhQ==",
+      "version": "1.7.1",
+      "resolved": "https://registry.npmjs.org/@playcanvas/eslint-config/-/eslint-config-1.7.1.tgz",
+      "integrity": "sha512-/mLrTrWjEMkrY9N8DzhMkwPNBue88RsdVzmxh+Y0rmBScaUrTu1Cj8FA/r1Tw5tXlAvqic7hhvnpL/wxuQcq6A==",
       "dev": true,
       "dependencies": {
         "eslint-plugin-import": "^2.28.0",
@@ -2290,15 +2296,15 @@
       "dev": true
     },
     "node_modules/@types/webxr": {
-      "version": "0.5.4",
-      "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.4.tgz",
-      "integrity": "sha512-41gfGLTtqXZhcmoDlLDHqMJDuwAMwhHwXf9Q2job3TUBsvkNfPNI/3IWVEtLH4tyY1ElWtfwIaoNeqeEX238/Q==",
+      "version": "0.5.5",
+      "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.5.tgz",
+      "integrity": "sha512-HVOsSRTQYx3zpVl0c0FBmmmcY/60BkQLzVnpE9M1aG4f2Z0aKlBWfj4XZ2zr++XNBfkQWYcwhGlmuu44RJPDqg==",
       "dev": true
     },
     "node_modules/@webgpu/types": {
-      "version": "0.1.34",
-      "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.34.tgz",
-      "integrity": "sha512-9mXtH+CC8q+Ku7Z+1XazNIte81FvfdXwR2lLRO7Ykzjd/hh1J1krJa0gtnkF1kvP11psUmKEPKo7iMTeEcUpNA==",
+      "version": "0.1.35",
+      "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.35.tgz",
+      "integrity": "sha512-6mh8zm/DDdtY6c+DXRmYc/7wJ1RQitFFmQiviwV8BK1XB75lXigN8AC8netNUO4XWTm7zEZevWgdMzXgThwOtA==",
       "dev": true
     },
     "node_modules/@zeit/schemas": {
@@ -2926,18 +2932,18 @@
       }
     },
     "node_modules/chai": {
-      "version": "4.3.8",
-      "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.8.tgz",
-      "integrity": "sha512-vX4YvVVtxlfSZ2VecZgFUTU5qPCYsobVI2O9FmwEXBhDigYGQA6jRXCycIs1yJnnWbZ6/+a2zNIF5DfVCcJBFQ==",
+      "version": "4.3.10",
+      "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.10.tgz",
+      "integrity": "sha512-0UXG04VuVbruMUYbJ6JctvH0YnC/4q3/AkT18q4NaITo91CUm0liMS9VqzT9vZhVQ/1eqPanMWjBM+Juhfb/9g==",
       "dev": true,
       "dependencies": {
         "assertion-error": "^1.1.0",
-        "check-error": "^1.0.2",
-        "deep-eql": "^4.1.2",
-        "get-func-name": "^2.0.0",
-        "loupe": "^2.3.1",
+        "check-error": "^1.0.3",
+        "deep-eql": "^4.1.3",
+        "get-func-name": "^2.0.2",
+        "loupe": "^2.3.6",
         "pathval": "^1.1.1",
-        "type-detect": "^4.0.5"
+        "type-detect": "^4.0.8"
       },
       "engines": {
         "node": ">=4"
@@ -3043,10 +3049,13 @@
       }
     },
     "node_modules/check-error": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz",
-      "integrity": "sha512-BrgHpW9NURQgzoNyjfq0Wu6VFO6D7IZEmJNdtgNqpzGG8RuNFHt2jQxWlAs4HMe119chBnv+34syEZtc6IhLtA==",
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz",
+      "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==",
       "dev": true,
+      "dependencies": {
+        "get-func-name": "^2.0.2"
+      },
       "engines": {
         "node": "*"
       }
@@ -3770,16 +3779,16 @@
       }
     },
     "node_modules/eslint": {
-      "version": "8.48.0",
-      "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.48.0.tgz",
-      "integrity": "sha512-sb6DLeIuRXxeM1YljSe1KEx9/YYeZFQWcV8Rq9HfigmdDEugjLEVEa1ozDjL6YDjBpQHPJxJzze+alxi4T3OLg==",
+      "version": "8.50.0",
+      "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.50.0.tgz",
+      "integrity": "sha512-FOnOGSuFuFLv/Sa+FDVRZl4GGVAAFFi8LecRsI5a1tMO5HIE8nCm4ivAlzt4dT3ol/PaaGC0rJEEXQmHJBGoOg==",
       "dev": true,
       "dependencies": {
         "@eslint-community/eslint-utils": "^4.2.0",
         "@eslint-community/regexpp": "^4.6.1",
         "@eslint/eslintrc": "^2.1.2",
-        "@eslint/js": "8.48.0",
-        "@humanwhocodes/config-array": "^0.11.10",
+        "@eslint/js": "8.50.0",
+        "@humanwhocodes/config-array": "^0.11.11",
         "@humanwhocodes/module-importer": "^1.0.1",
         "@nodelib/fs.walk": "^1.2.8",
         "ajv": "^6.12.4",
@@ -4325,9 +4334,9 @@
       }
     },
     "node_modules/fflate": {
-      "version": "0.8.0",
-      "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.0.tgz",
-      "integrity": "sha512-FAdS4qMuFjsJj6XHbBaZeXOgaypXp8iw/Tpyuq/w3XA41jjLHT8NPA+n7czH/DDhdncq0nAyDZmPeWXh2qmdIg==",
+      "version": "0.8.1",
+      "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.1.tgz",
+      "integrity": "sha512-/exOvEuc+/iaUm105QIiOt4LpBdMTWsXxqR0HDF35vx3fmaKzw7354gTilCh5rkzEt8WYyG//ku3h3nRmd7CHQ==",
       "dev": true
     },
     "node_modules/file-entry-cache": {
@@ -4571,9 +4580,9 @@
       }
     },
     "node_modules/get-func-name": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz",
-      "integrity": "sha512-Hm0ixYtaSZ/V7C8FJrtZIuBBI+iSgL+1Aq82zSu8VQNB4S3Gk8e7Qs3VwBDJAhmRZcFqkl3tQu36g/Foh5I5ig==",
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz",
+      "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==",
       "dev": true,
       "engines": {
         "node": "*"
@@ -7114,9 +7123,9 @@
       }
     },
     "node_modules/rollup": {
-      "version": "3.28.1",
-      "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.28.1.tgz",
-      "integrity": "sha512-R9OMQmIHJm9znrU3m3cpE8uhN0fGdXiawME7aZIpQqvpS/85+Vt1Hq1/yVIcYfOmaQiHjvXkQAoJukvLpau6Yw==",
+      "version": "3.29.4",
+      "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.4.tgz",
+      "integrity": "sha512-oWzmBZwvYrU0iJHtDmhsm662rC15FRXmcjCk1xD771dFDx5jJ02ufAQQTn0etB2emNk4J9EZg/yWKpsn9BWGRw==",
       "dev": true,
       "bin": {
         "rollup": "dist/bin/rollup"
@@ -7130,31 +7139,31 @@
       }
     },
     "node_modules/rollup-plugin-dts": {
-      "version": "6.0.0",
-      "resolved": "https://registry.npmjs.org/rollup-plugin-dts/-/rollup-plugin-dts-6.0.0.tgz",
-      "integrity": "sha512-A996xSZDAqnx/KfFttzC8mDEuyMjsRpiLCrlGc8effhK8KhE3AG0g1woQiITgFc5HSE8HWU7ccR9CiQ3vXgUlQ==",
+      "version": "6.0.2",
+      "resolved": "https://registry.npmjs.org/rollup-plugin-dts/-/rollup-plugin-dts-6.0.2.tgz",
+      "integrity": "sha512-GYCCy9DyE5csSuUObktJBpjNpW2iLZMabNDIiAqzQWBl7l/WHzjvtAXevf8Lftk8EA920tuxeB/g8dM8MVMR6A==",
       "dev": true,
       "dependencies": {
-        "magic-string": "^0.30.2"
+        "magic-string": "^0.30.3"
       },
       "engines": {
-        "node": ">=v18.17.1"
+        "node": ">=v16"
       },
       "funding": {
         "url": "https://github.com/sponsors/Swatinem"
       },
       "optionalDependencies": {
-        "@babel/code-frame": "^7.22.10"
+        "@babel/code-frame": "^7.22.13"
       },
       "peerDependencies": {
-        "rollup": "^3.25.0",
+        "rollup": "^3.25",
         "typescript": "^4.5 || ^5.0"
       }
     },
     "node_modules/rollup-plugin-dts/node_modules/magic-string": {
-      "version": "0.30.2",
-      "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.2.tgz",
-      "integrity": "sha512-lNZdu7pewtq/ZvWUp9Wpf/x7WzMTsR26TWV03BRZrXFsv+BI6dy8RAiKgm1uM/kyR0rCfUcqvOlXKG66KhIGug==",
+      "version": "0.30.4",
+      "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.4.tgz",
+      "integrity": "sha512-Q/TKtsC5BPm0kGqgBIF9oXAs/xEf2vRKiIB4wCRQTJOQIByZ1d+NnUOotvJOvNpi5RNIgVOMC3pOuaP1ZTDlVg==",
       "dev": true,
       "dependencies": {
         "@jridgewell/sourcemap-codec": "^1.4.15"
@@ -7503,9 +7512,9 @@
       "dev": true
     },
     "node_modules/sinon": {
-      "version": "15.2.0",
-      "resolved": "https://registry.npmjs.org/sinon/-/sinon-15.2.0.tgz",
-      "integrity": "sha512-nPS85arNqwBXaIsFCkolHjGIkFo+Oxu9vbgmBJizLAhqe6P2o3Qmj3KCUoRkfhHtvgDhZdWD3risLHAUJ8npjw==",
+      "version": "16.0.0",
+      "resolved": "https://registry.npmjs.org/sinon/-/sinon-16.0.0.tgz",
+      "integrity": "sha512-B8AaZZm9CT5pqe4l4uWJztfD/mOTa7dL8Qo0W4+s+t74xECOgSZDDQCBjNgIK3+n4kyxQrSTv2V5ul8K25qkiQ==",
       "dev": true,
       "dependencies": {
         "@sinonjs/commons": "^3.0.0",
@@ -8047,9 +8056,9 @@
       }
     },
     "node_modules/typedoc": {
-      "version": "0.25.0",
-      "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.25.0.tgz",
-      "integrity": "sha512-FvCYWhO1n5jACE0C32qg6b3dSfQ8f2VzExnnRboowHtqUD6ARzM2r8YJeZFYXhcm2hI4C2oCRDgNPk/yaQUN9g==",
+      "version": "0.25.1",
+      "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.25.1.tgz",
+      "integrity": "sha512-c2ye3YUtGIadxN2O6YwPEXgrZcvhlZ6HlhWZ8jQRNzwLPn2ylhdGqdR8HbyDRyALP8J6lmSANILCkkIdNPFxqA==",
       "dev": true,
       "dependencies": {
         "lunr": "^2.3.9",
diff --git a/package.json b/package.json
index d8b7bb91a..4cd5b7590 100644
--- a/package.json
+++ b/package.json
@@ -92,23 +92,23 @@
     "README-zh.md"
   ],
   "devDependencies": {
-    "@babel/core": "^7.22.11",
-    "@babel/eslint-parser": "^7.22.11",
-    "@babel/preset-env": "^7.22.10",
+    "@babel/core": "^7.23.0",
+    "@babel/eslint-parser": "^7.22.15",
+    "@babel/preset-env": "^7.22.20",
     "@playcanvas/canvas-mock": "^1.0.1",
-    "@playcanvas/eslint-config": "^1.7.0",
+    "@playcanvas/eslint-config": "^1.7.1",
     "@playcanvas/jsdoc-template": "^1.1.2",
     "@rollup/plugin-babel": "^6.0.3",
     "@rollup/plugin-node-resolve": "^15.2.1",
     "@rollup/plugin-strip": "^3.0.2",
     "@rollup/plugin-terser": "^0.4.3",
     "@rollup/pluginutils": "^5.0.4",
-    "@types/webxr": "^0.5.4",
-    "@webgpu/types": "^0.1.34",
+    "@types/webxr": "^0.5.5",
+    "@webgpu/types": "^0.1.35",
     "c8": "^8.0.0",
-    "chai": "^4.3.8",
-    "eslint": "^8.48.0",
-    "fflate": "^0.8.0",
+    "chai": "^4.3.10",
+    "eslint": "^8.50.0",
+    "fflate": "^0.8.1",
     "jsdoc": "^4.0.2",
     "jsdoc-tsimport-plugin": "^1.0.5",
     "karma": "^6.4.2",
@@ -116,13 +116,13 @@
     "karma-mocha": "2.0.1",
     "karma-spec-reporter": "^0.0.36",
     "mocha": "^10.2.0",
-    "rollup": "^3.28.1",
-    "rollup-plugin-dts": "^6.0.0",
+    "rollup": "^3.29.4",
+    "rollup-plugin-dts": "^6.0.2",
     "rollup-plugin-jscc": "2.0.0",
     "rollup-plugin-visualizer": "^5.9.2",
     "serve": "^14.2.1",
-    "sinon": "^15.2.0",
-    "typedoc": "^0.25.0",
+    "sinon": "^16.0.0",
+    "typedoc": "^0.25.1",
     "typedoc-plugin-mdn-links": "^3.1.0",
     "typescript": "^5.2.2",
     "xhr2": "^0.2.1"
diff --git a/rollup.config.mjs b/rollup.config.mjs
index e944205aa..30b79e216 100644
--- a/rollup.config.mjs
+++ b/rollup.config.mjs
@@ -1,9 +1,7 @@
-import { execSync } from 'child_process';
-import fs from 'fs';
-import path from 'path';
+import { execSync } from 'node:child_process';
+import * as fs from 'node:fs';
 
 // 1st party Rollup plugins
-import { createFilter } from '@rollup/pluginutils';
 import { babel } from '@rollup/plugin-babel';
 import resolve from '@rollup/plugin-node-resolve';
 import strip from '@rollup/plugin-strip';
@@ -13,10 +11,13 @@ import terser from '@rollup/plugin-terser';
 import dts from 'rollup-plugin-dts';
 import jscc from 'rollup-plugin-jscc';
 import { visualizer } from 'rollup-plugin-visualizer';
+
+// custom Rollup plugins
 import { shaderChunks } from './utils/rollup-shader-chunks.mjs';
+import { engineLayerImportValidation } from './utils/rollup-import-validation.mjs';
+import { spacesToTabs } from './utils/rollup-spaces-to-tabs.mjs';
 
 /** @typedef {import('rollup').RollupOptions} RollupOptions */
-/** @typedef {import('rollup').Plugin} Plugin */
 /** @typedef {import('rollup').OutputOptions} OutputOptions */
 /** @typedef {import('@rollup/plugin-babel').RollupBabelInputPluginOptions} RollupBabelInputPluginOptions */
 /** @typedef {import('@rollup/plugin-strip').RollupStripOptions} RollupStripOptions */
@@ -63,93 +64,6 @@ function getBanner(config) {
     ].join('\n');
 }
 
-/**
- * This plugin converts every two spaces into one tab. Two spaces is the default the babel plugin
- * outputs, which is independent of the four spaces of the code base.
- *
- * @param {boolean} enable - Enable or disable the plugin.
- * @returns {Plugin} The plugin.
- */
-function spacesToTabs(enable) {
-    const filter = createFilter([
-        '**/*.js'
-    ], []);
-
-    return {
-        name: "spacesToTabs",
-        transform(code, id) {
-            if (!enable || !filter(id)) return undefined;
-            // ^    = start of line
-            // " +" = one or more spaces
-            // gm   = find all + multiline
-            const regex = /^ +/gm;
-            code = code.replace(
-                regex,
-                startSpaces => startSpaces.replace(/ {2}/g, '\t')
-            );
-            return {
-                code,
-                map: null
-            };
-        }
-    };
-}
-
-/**
- * Validate and print warning if an engine module on a lower level imports module on a higher level
- *
- * @param {string} rootFile - The root file, typically `src/index.js`.
- * @param {boolean} enable - Enable or disable the plugin.
- * @returns {Plugin} The plugin.
- */
-function engineLayerImportValidation(rootFile, enable) {
-
-    const folderLevels = {
-        'core': 0,
-        'platform': 1,
-        'scene': 2,
-        'framework': 3
-    };
-
-    let rootPath;
-
-    return {
-        name: 'engineLayerImportValidation',
-
-        buildStart() {
-            rootPath = path.parse(path.resolve(rootFile)).dir;
-        },
-
-        resolveId(imported, importer) {
-            if (enable) {
-
-                // skip non-relative paths, those are not our imports, for example 'rollupPluginBabelHelpers.js'
-                if (importer && imported && imported.includes('./')) {
-
-                    // convert importer path
-                    const importerDir = path.parse(importer).dir;
-                    const relImporter = path.dirname(path.relative(rootPath, importer));
-                    const folderImporter = relImporter.split(path.sep)[0];
-                    const levelImporter = folderLevels[folderImporter];
-
-                    // convert imported path
-                    const absImported = path.resolve(path.join(importerDir, imported));
-                    const relImported = path.dirname(path.relative(rootPath, absImported));
-                    const folderImported = relImported.split(path.sep)[0];
-                    const levelImported = folderLevels[folderImported];
-
-                    if (levelImporter < levelImported) {
-                        console.log(`(!) Incorrect import: [${path.relative(rootPath, importer)}] -> [${imported}]`);
-                    }
-                }
-            }
-
-            // we don't process imports, return null to allow chaining
-            return null;
-        }
-    };
-}
-
 /**
  * The ES5 options for babel(...) plugin.
  *
@@ -440,6 +354,12 @@ export default (args) => {
     let targets = [];
 
     const envTarget = process.env.target ? process.env.target.toLowerCase() : null;
+
+    if ((envTarget === null) && fs.existsSync('build')) {
+        // no targets specified, clean build directory
+        fs.rmSync('build', { recursive: true });
+    }
+
     if (envTarget === 'types') {
         targets.push(target_types);
     } else if (envTarget === 'extras') {
diff --git a/src/core/math/vec2.js b/src/core/math/vec2.js
index 10add95fa..97765fbe2 100644
--- a/src/core/math/vec2.js
+++ b/src/core/math/vec2.js
@@ -103,6 +103,27 @@ class Vec2 {
         return this;
     }
 
+    /**
+     * Adds a 2-dimensional vector scaled by scalar value. Does not modify the vector being added.
+     *
+     * @param {Vec2} rhs - The vector to add to the specified vector.
+     * @param {number} scalar - The number to multiply the added vector with.
+     * @returns {Vec2} Self for chaining.
+     * @example
+     * const vec = new pc.Vec2(1, 2);
+     *
+     * vec.addScaled(pc.Vec2.UP, 2);
+     *
+     * // Outputs [1, 4]
+     * console.log("The result of the addition is: " + vec.toString());
+     */
+    addScaled(rhs, scalar) {
+        this.x += rhs.x * scalar;
+        this.y += rhs.y * scalar;
+
+        return this;
+    }
+
     /**
      * Returns an identical copy of the specified 2-dimensional vector.
      *
diff --git a/src/core/math/vec3.js b/src/core/math/vec3.js
index 7ebd8f9d3..e5b3b79b3 100644
--- a/src/core/math/vec3.js
+++ b/src/core/math/vec3.js
@@ -114,6 +114,28 @@ class Vec3 {
         return this;
     }
 
+    /**
+     * Adds a 3-dimensional vector scaled by scalar value. Does not modify the vector being added.
+     *
+     * @param {Vec3} rhs - The vector to add to the specified vector.
+     * @param {number} scalar - The number to multiply the added vector with.
+     * @returns {Vec3} Self for chaining.
+     * @example
+     * const vec = new pc.Vec3(1, 2, 3);
+     *
+     * vec.addScaled(pc.Vec3.UP, 2);
+     *
+     * // Outputs [1, 4, 3]
+     * console.log("The result of the addition is: " + vec.toString());
+     */
+    addScaled(rhs, scalar) {
+        this.x += rhs.x * scalar;
+        this.y += rhs.y * scalar;
+        this.z += rhs.z * scalar;
+
+        return this;
+    }
+
     /**
      * Returns an identical copy of the specified 3-dimensional vector.
      *
diff --git a/src/core/math/vec4.js b/src/core/math/vec4.js
index ea56ecca1..2e17f1931 100644
--- a/src/core/math/vec4.js
+++ b/src/core/math/vec4.js
@@ -127,6 +127,29 @@ class Vec4 {
         return this;
     }
 
+    /**
+     * Adds a 4-dimensional vector scaled by scalar value. Does not modify the vector being added.
+     *
+     * @param {Vec4} rhs - The vector to add to the specified vector.
+     * @param {number} scalar - The number to multiply the added vector with.
+     * @returns {Vec4} Self for chaining.
+     * @example
+     * const vec = new pc.Vec4(1, 2, 3, 4);
+     *
+     * vec.addScaled(pc.Vec4.ONE, 2);
+     *
+     * // Outputs [3, 4, 5, 6]
+     * console.log("The result of the addition is: " + vec.toString());
+     */
+    addScaled(rhs, scalar) {
+        this.x += rhs.x * scalar;
+        this.y += rhs.y * scalar;
+        this.z += rhs.z * scalar;
+        this.w += rhs.w * scalar;
+
+        return this;
+    }
+
     /**
      * Returns an identical copy of the specified 4-dimensional vector.
      *
diff --git a/src/framework/app-base.js b/src/framework/app-base.js
index 0fcee2973..a0d08132d 100644
--- a/src/framework/app-base.js
+++ b/src/framework/app-base.js
@@ -1922,6 +1922,7 @@ class AppBase extends EventHandler {
 
         const canvasId = this.graphicsDevice.canvas.id;
 
+        this.fire('destroy', this); // fire destroy event
         this.off('librariesloaded');
 
         if (typeof document !== 'undefined') {
diff --git a/src/framework/components/anim/component.js b/src/framework/components/anim/component.js
index 70dd6bebd..a5740042c 100644
--- a/src/framework/components/anim/component.js
+++ b/src/framework/components/anim/component.js
@@ -378,7 +378,7 @@ class AnimComponent extends Component {
         let containsBlendTree = false;
         for (let i = 0; i < stateGraph.layers.length; i++) {
             const layer = stateGraph.layers[i];
-            this._addLayer.bind(this)({ ...layer });
+            this._addLayer({ ...layer });
             if (layer.states.some(state => state.blendTree)) {
                 containsBlendTree = true;
             }
diff --git a/src/framework/components/camera/component.js b/src/framework/components/camera/component.js
index 7e6c468d2..e320bfa16 100644
--- a/src/framework/components/camera/component.js
+++ b/src/framework/components/camera/component.js
@@ -891,7 +891,7 @@ class CameraComponent extends Component {
     /**
      * Calculates aspect ratio value for a given render target.
      *
-     * @param {import('../../../platform/graphics/render-target.js').RenderTarget} [rt] - Optional
+     * @param {import('../../../platform/graphics/render-target.js').RenderTarget|null} [rt] - Optional
      * render target. If unspecified, the backbuffer is used.
      * @returns {number} The aspect ratio of the render target (or backbuffer).
      */
@@ -905,7 +905,7 @@ class CameraComponent extends Component {
     /**
      * Prepare the camera for frame rendering.
      *
-     * @param {import('../../../platform/graphics/render-target.js').RenderTarget} rt - Render
+     * @param {import('../../../platform/graphics/render-target.js').RenderTarget|null} rt - Render
      * target to which rendering will be performed. Will affect camera's aspect ratio, if
      * aspectRatioMode is {@link ASPECT_AUTO}.
      * @ignore
diff --git a/src/framework/font/canvas-font.js b/src/framework/font/canvas-font.js
index 4f8e98878..2b8edded5 100644
--- a/src/framework/font/canvas-font.js
+++ b/src/framework/font/canvas-font.js
@@ -13,6 +13,46 @@ import { Texture } from '../../platform/graphics/texture.js';
 const MAX_TEXTURE_SIZE = 4096;
 const DEFAULT_TEXTURE_SIZE = 512;
 
+class Atlas {
+    constructor(device, width, height, name) {
+        this.canvas = document.createElement('canvas');
+        this.canvas.width = width;
+        this.canvas.height = height;
+
+        this.texture = new Texture(device, {
+            name: name,
+            format: PIXELFORMAT_RGBA8,
+            width: width,
+            height: height,
+            mipmaps: true,
+            minFilter: FILTER_LINEAR_MIPMAP_LINEAR,
+            magFilter: FILTER_LINEAR,
+            addressU: ADDRESS_CLAMP_TO_EDGE,
+            addressV: ADDRESS_CLAMP_TO_EDGE,
+            levels: [this.canvas]
+        });
+
+        this.ctx = this.canvas.getContext('2d', {
+            alpha: true
+        });
+    }
+
+    destroy() {
+        this.texture.destroy();
+    }
+
+    clear(clearColor) {
+        const { width, height } = this.canvas;
+
+        // clear to black first to remove everything as clear color is transparent
+        this.ctx.clearRect(0, 0, width, height);
+
+        // clear to color
+        this.ctx.fillStyle = clearColor;
+        this.ctx.fillRect(0, 0, width, height);
+    }
+}
+
 /**
  * Represents the resource of a canvas font asset.
  *
@@ -52,27 +92,9 @@ class CanvasFont extends EventHandler {
         this.color = options.color || new Color(1, 1, 1);
         this.padding = options.padding || 0;
 
-        const w = options.width > MAX_TEXTURE_SIZE ? MAX_TEXTURE_SIZE : (options.width || DEFAULT_TEXTURE_SIZE);
-        const h = options.height > MAX_TEXTURE_SIZE ? MAX_TEXTURE_SIZE : (options.height || DEFAULT_TEXTURE_SIZE);
-
-        // Create a canvas to do the text rendering
-        const canvas = document.createElement('canvas');
-        canvas.height = h;
-        canvas.width = w;
-
-        const texture = new Texture(this.app.graphicsDevice, {
-            name: 'font',
-            format: PIXELFORMAT_RGBA8,
-            minFilter: FILTER_LINEAR_MIPMAP_LINEAR,
-            magFilter: FILTER_LINEAR,
-            addressU: ADDRESS_CLAMP_TO_EDGE,
-            addressV: ADDRESS_CLAMP_TO_EDGE,
-            mipmaps: true
-        });
-
-        texture.setSource(canvas);
-
-        this.textures = [texture];
+        this.width = Math.min(MAX_TEXTURE_SIZE, options.width || DEFAULT_TEXTURE_SIZE);
+        this.height = Math.min(MAX_TEXTURE_SIZE, options.height || DEFAULT_TEXTURE_SIZE);
+        this.atlases = [];
 
         this.chars = '';
         this.data = {};
@@ -127,10 +149,8 @@ class CanvasFont extends EventHandler {
      * Destroys the font. This also destroys the textures owned by the font.
      */
     destroy() {
-        // call texture.destroy on any created textures
-        for (let i = 0; i < this.textures.length; i++) {
-            this.textures[i].destroy();
-        }
+        this.atlases.forEach(atlas => atlas.destroy());
+
         // null instance variables to make it obvious this font is no longer valid
         this.chars = null;
         this.color = null;
@@ -139,32 +159,11 @@ class CanvasFont extends EventHandler {
         this.fontSize = null;
         this.glyphSize = null;
         this.intensity = null;
-        this.textures = null;
+        this.atlases = null;
         this.type = null;
         this.fontWeight = null;
     }
 
-    /**
-     * @param {HTMLCanvasElement} canvas - The canvas used to render the font.
-     * @param {string} clearColor - The color to clear the canvas with.
-     * @returns {CanvasRenderingContext2D} - A 2D rendering contxt.
-     * @private
-     */
-    _getAndClearContext(canvas, clearColor) {
-        const w = canvas.width;
-        const h = canvas.height;
-
-        const ctx = canvas.getContext('2d', {
-            alpha: true
-        });
-
-        ctx.clearRect(0, 0, w, h);  // clear to black first to remove everything as clear color is transparent
-        ctx.fillStyle = clearColor;
-        ctx.fillRect(0, 0, w, h);   // clear to color
-
-        return ctx;
-    }
-
     /**
      * @param {Color} color - The color to covert.
      * @param {boolean} alpha - Whether to include the alpha channel.
@@ -191,7 +190,7 @@ class CanvasFont extends EventHandler {
      * @param {string} char - The character to render.
      * @param {number} x - The x position to render the character at.
      * @param {number} y - The y position to render the character at.
-     * @param {number} color - The color to render the character in.
+     * @param {string} color - The color to render the character in.
      * @ignore
      */
     renderCharacter(context, char, x, y, color) {
@@ -199,6 +198,19 @@ class CanvasFont extends EventHandler {
         context.fillText(char, x, y);
     }
 
+    /**
+     * Return the atlas at the specified index.
+     *
+     * @param {number} index - The atlas index
+     * @private
+     */
+    _getAtlas(index) {
+        if (index >= this.atlases.length) {
+            this.atlases[index] = new Atlas(this.app.graphicsDevice, this.width, this.height, `font-atlas-${this.fontName}-${index}`);
+        }
+        return this.atlases[index];
+    }
+
     /**
      * Renders an array of characters into one or more textures atlases.
      *
@@ -208,11 +220,8 @@ class CanvasFont extends EventHandler {
     _renderAtlas(charsArray) {
         this.chars = charsArray;
 
-        let numTextures = 1;
-
-        let canvas = this.textures[numTextures - 1].getSource();
-        const w = canvas.width;
-        const h = canvas.height;
+        const w = this.width;
+        const h = this.height;
 
         // fill color
         const color = this._colorToRgbString(this.color, false);
@@ -228,16 +237,13 @@ class CanvasFont extends EventHandler {
         const TEXT_ALIGN = 'center';
         const TEXT_BASELINE = 'alphabetic';
 
-        let ctx = this._getAndClearContext(canvas, transparent);
-
-        ctx.font = this.fontWeight + ' ' + this.fontSize.toString() + 'px ' + this.fontName;
-        ctx.textAlign = TEXT_ALIGN;
-        ctx.textBaseline = TEXT_BASELINE;
+        let atlasIndex = 0;
+        let atlas = this._getAtlas(atlasIndex++);
+        atlas.clear(transparent);
 
         this.data = this._createJson(this.chars, this.fontName, w, h);
 
         const symbols = string.getSymbols(this.chars.join(''));
-        const prevNumTextures = this.textures.length;
 
         let maxHeight = 0;
         let maxDescent = 0;
@@ -263,25 +269,25 @@ class CanvasFont extends EventHandler {
             const code = string.getCodePoint(symbols[i]);
 
             let fs = this.fontSize;
-            ctx.font = this.fontWeight + ' ' + fs.toString() + 'px ' + this.fontName;
-            ctx.textAlign = TEXT_ALIGN;
-            ctx.textBaseline = TEXT_BASELINE;
+            atlas.ctx.font = this.fontWeight + ' ' + fs.toString() + 'px ' + this.fontName;
+            atlas.ctx.textAlign = TEXT_ALIGN;
+            atlas.ctx.textBaseline = TEXT_BASELINE;
 
-            let width = ctx.measureText(ch).width;
+            let width = atlas.ctx.measureText(ch).width;
 
             if (width > fs) {
                 fs = this.fontSize * this.fontSize / width;
-                ctx.font = this.fontWeight + ' ' + fs.toString() + 'px ' + this.fontName;
+                atlas.ctx.font = this.fontWeight + ' ' + fs.toString() + 'px ' + this.fontName;
                 width = this.fontSize;
             }
 
-            this.renderCharacter(ctx, ch, _x + _xOffset, _y + _yOffset, color);
+            this.renderCharacter(atlas.ctx, ch, _x + _xOffset, _y + _yOffset, color);
 
             const xoffset = this.padding + (this.glyphSize - width) / 2;
             const yoffset = -this.padding + metrics[ch].descent - maxDescent;
             const xadvance = width;
 
-            this._addChar(this.data, ch, code, _x, _y, sx, sy, xoffset, yoffset, xadvance, numTextures - 1, w, h);
+            this._addChar(this.data, ch, code, _x, _y, sx, sy, xoffset, yoffset, xadvance, atlasIndex - 1, w, h);
 
             _x += sx;
             if (_x + sx > w) {
@@ -290,46 +296,18 @@ class CanvasFont extends EventHandler {
                 _y += sy;
                 if (_y + sy > h) {
                     // We ran out of space on this texture!
-                    // Copy the canvas into the texture and upload it
-                    this.textures[numTextures - 1].upload();
-                    // Create a new texture (if needed) and continue on
-                    numTextures++;
+                    atlas = this._getAtlas(atlasIndex++);
+                    atlas.clear(transparent);
                     _y = 0;
-                    if (numTextures > prevNumTextures) {
-                        canvas = document.createElement('canvas');
-                        canvas.height = h;
-                        canvas.width = w;
-
-                        ctx = this._getAndClearContext(canvas, transparent);
-
-                        const texture = new Texture(this.app.graphicsDevice, {
-                            format: PIXELFORMAT_RGBA8,
-                            mipmaps: true,
-                            name: 'font-atlas'
-                        });
-                        texture.setSource(canvas);
-                        texture.minFilter = FILTER_LINEAR_MIPMAP_LINEAR;
-                        texture.magFilter = FILTER_LINEAR;
-                        texture.addressU = ADDRESS_CLAMP_TO_EDGE;
-                        texture.addressV = ADDRESS_CLAMP_TO_EDGE;
-                        this.textures.push(texture);
-                    } else {
-                        canvas = this.textures[numTextures - 1].getSource();
-                        ctx = this._getAndClearContext(canvas, transparent);
-                    }
                 }
             }
         }
-        // Copy any remaining characters in the canvas into the last texture and upload it
-        this.textures[numTextures - 1].upload();
 
-        // Cleanup any remaining (unused) textures
-        if (numTextures < prevNumTextures) {
-            for (let i = numTextures; i < prevNumTextures; i++) {
-                this.textures[i].destroy();
-            }
-            this.textures.splice(numTextures);
-        }
+        // remove any unused characters
+        this.atlases.splice(atlasIndex).forEach(atlas => atlas.destroy());
+
+        // upload textures
+        this.atlases.forEach(atlas => atlas.texture.upload());
 
         // alert text-elements that the font has been re-rendered
         this.fire('render');
@@ -476,6 +454,11 @@ class CanvasFont extends EventHandler {
             height: height
         };
     }
+
+    // nasty, other systems are accessing textures directly
+    get textures() {
+        return this.atlases.map(atlas => atlas.texture);
+    }
 }
 
 export { CanvasFont };
diff --git a/src/framework/input/element-input.js b/src/framework/input/element-input.js
index 46d6b4762..b08dd3abc 100644
--- a/src/framework/input/element-input.js
+++ b/src/framework/input/element-input.js
@@ -638,8 +638,6 @@ class ElementInput {
             delete this._touchedElements[touch.identifier];
             delete this._touchesForWhichTouchLeaveHasFired[touch.identifier];
 
-            this._fireEvent(event.type, new ElementTouchEvent(event, element, camera, x, y, touch));
-
             // check if touch was released over previously touch
             // element in order to fire click event
             const coords = this._calcTouchCoords(touch);
@@ -655,6 +653,8 @@ class ElementInput {
 
                 }
             }
+
+            this._fireEvent(event.type, new ElementTouchEvent(event, element, camera, x, y, touch));
         }
     }
 
diff --git a/src/framework/parsers/glb-parser.js b/src/framework/parsers/glb-parser.js
index 8ea6285a1..c97c393be 100644
--- a/src/framework/parsers/glb-parser.js
+++ b/src/framework/parsers/glb-parser.js
@@ -491,12 +491,20 @@ const createVertexBufferInternal = (device, sourceDesc, flipV) => {
     const vertexDesc = [];
     for (const semantic in sourceDesc) {
         if (sourceDesc.hasOwnProperty(semantic)) {
-            vertexDesc.push({
+            const element = {
                 semantic: semantic,
                 components: sourceDesc[semantic].components,
                 type: sourceDesc[semantic].type,
                 normalize: !!sourceDesc[semantic].normalize
-            });
+            };
+
+            if (!VertexFormat.isElementValid(device, element)) {
+                // WebGP does not support some formats and we need to remap it to one larger, for example int16x3 -> int16x4
+                // TODO: this might need the actual data changes if this element is the last one in the vertex, as it might
+                // try to read outside of the vertex buffer.
+                element.components++;
+            }
+            vertexDesc.push(element);
         }
     }
 
@@ -1038,14 +1046,14 @@ const extensionUnlit = (data, material, textures) => {
 
 const extensionSpecular = (data, material, textures) => {
     material.useMetalnessSpecularColor = true;
+
     if (data.hasOwnProperty('specularColorTexture')) {
         material.specularEncoding = 'srgb';
         material.specularMap = textures[data.specularColorTexture.index];
         material.specularMapChannel = 'rgb';
-
         extractTextureTransform(data.specularColorTexture, material, ['specular']);
-
     }
+
     if (data.hasOwnProperty('specularColorFactor')) {
         const color = data.specularColorFactor;
         material.specular.set(Math.pow(color[0], 1 / 2.2), Math.pow(color[1], 1 / 2.2), Math.pow(color[2], 1 / 2.2));
@@ -1058,6 +1066,7 @@ const extensionSpecular = (data, material, textures) => {
     } else {
         material.specularityFactor = 1;
     }
+
     if (data.hasOwnProperty('specularTexture')) {
         material.specularityFactorMapChannel = 'a';
         material.specularityFactorMap = textures[data.specularTexture.index];
@@ -1272,9 +1281,10 @@ const createMaterial = (gltfMaterial, textures, flipV) => {
                 }
                 break;
             case 'BLEND':
+                // Blended materials will be rendered back to front, but continue to write depth
+                // along with alpha test. This helps to composite the scene.
                 material.blendType = BLEND_NORMAL;
-                // note: by default don't write depth on semitransparent materials
-                material.depthWrite = false;
+                material.alphaTest = 1.0 / 255.0;
                 break;
             default:
             case 'OPAQUE':
diff --git a/src/platform/graphics/graphics-device.js b/src/platform/graphics/graphics-device.js
index 41aefc192..ddc42e905 100644
--- a/src/platform/graphics/graphics-device.js
+++ b/src/platform/graphics/graphics-device.js
@@ -358,10 +358,6 @@ class GraphicsDevice extends EventHandler {
         this.initOptions.antialias ??= true;
         this.initOptions.powerPreference ??= 'high-performance';
 
-        // local width/height without pixelRatio applied
-        this._width = 0;
-        this._height = 0;
-
         // Some devices window.devicePixelRatio can be less than one
         // eg Oculus Quest 1 which returns a window.devicePixelRatio of 0.8
         this._maxPixelRatio = platform.browser ? Math.min(1, window.devicePixelRatio) : 1;
@@ -651,6 +647,12 @@ class GraphicsDevice extends EventHandler {
      * @ignore
      */
     resizeCanvas(width, height) {
+        const pixelRatio = Math.min(this._maxPixelRatio, platform.browser ? window.devicePixelRatio : 1);
+        const w = Math.floor(width * pixelRatio);
+        const h = Math.floor(height * pixelRatio);
+        if (w !== this.canvas.width || h !== this.canvas.height) {
+            this.setResolution(w, h);
+        }
     }
 
     /**
@@ -662,8 +664,6 @@ class GraphicsDevice extends EventHandler {
      * @ignore
      */
     setResolution(width, height) {
-        this._width = width;
-        this._height = height;
         this.canvas.width = width;
         this.canvas.height = height;
         this.fire(GraphicsDevice.EVENT_RESIZE, width, height);
@@ -679,7 +679,6 @@ class GraphicsDevice extends EventHandler {
      * @type {number}
      */
     get width() {
-        Debug.error("GraphicsDevice.width is not implemented on current device.");
         return this.canvas.width;
     }
 
@@ -689,7 +688,6 @@ class GraphicsDevice extends EventHandler {
      * @type {number}
      */
     get height() {
-        Debug.error("GraphicsDevice.height is not implemented on current device.");
         return this.canvas.height;
     }
 
@@ -713,10 +711,7 @@ class GraphicsDevice extends EventHandler {
      * @type {number}
      */
     set maxPixelRatio(ratio) {
-        if (this._maxPixelRatio !== ratio) {
-            this._maxPixelRatio = ratio;
-            this.resizeCanvas(this._width, this._height);
-        }
+        this._maxPixelRatio = ratio;
     }
 
     get maxPixelRatio() {
diff --git a/src/platform/graphics/null/null-graphics-device.js b/src/platform/graphics/null/null-graphics-device.js
index 3ebf61124..54b36f2c9 100644
--- a/src/platform/graphics/null/null-graphics-device.js
+++ b/src/platform/graphics/null/null-graphics-device.js
@@ -66,11 +66,6 @@ class NullGraphicsDevice extends GraphicsDevice {
         super.postInit();
     }
 
-    resizeCanvas(width, height) {
-        this._width = width;
-        this._height = height;
-    }
-
     frameStart() {
         super.frameStart();
     }
@@ -137,14 +132,6 @@ class NullGraphicsDevice extends GraphicsDevice {
     clear(options) {
     }
 
-    get width() {
-        return this._width;
-    }
-
-    get height() {
-        return this._height;
-    }
-
     setDepthBias(on) {
     }
 
diff --git a/src/platform/graphics/render-pass.js b/src/platform/graphics/render-pass.js
index ee0ca1dad..3fe8efac8 100644
--- a/src/platform/graphics/render-pass.js
+++ b/src/platform/graphics/render-pass.js
@@ -93,7 +93,13 @@ class RenderPass {
     /** @type {string} */
     name;
 
-    /** @type {import('../graphics/render-target.js').RenderTarget} */
+    /**
+     * The render target for this render pass:
+     *  - `undefined`: render pass does not render to any render target
+     *  - `null`: render pass renders to the backbuffer
+     *  - Otherwise, renders to the provided RT.
+     * @type {import('../graphics/render-target.js').RenderTarget|null|undefined}
+     */
     renderTarget;
 
     /**
@@ -186,15 +192,15 @@ class RenderPass {
     }
 
     /**
-     * @param {import('../graphics/render-target.js').RenderTarget} renderTarget - The render
+     * @param {import('../graphics/render-target.js').RenderTarget|null} [renderTarget] - The render
      * target to render into (output). This function should be called only for render passes which
      * use render target, or passes which render directly into the default framebuffer, in which
      * case a null or undefined render target is expected.
      */
-    init(renderTarget) {
+    init(renderTarget = null) {
 
         // null represents the default framebuffer
-        this.renderTarget = renderTarget || null;
+        this.renderTarget = renderTarget;
 
         // defaults depend on multisampling
         this.samples = Math.max(this.renderTarget ? this.renderTarget.samples : this.device.samples, 1);
diff --git a/src/platform/graphics/texture.js b/src/platform/graphics/texture.js
index 3bdf6e4ba..cf7d3f874 100644
--- a/src/platform/graphics/texture.js
+++ b/src/platform/graphics/texture.js
@@ -157,7 +157,7 @@ class Texture {
      * - {@link FUNC_NOTEQUAL}
      *
      * Defaults to {@link FUNC_LESS}.
-     * @param {Uint8Array[]} [options.levels] - Array of Uint8Array.
+     * @param {Uint8Array[]|HTMLCanvasElement[]|HTMLImageElement[]|HTMLVideoElement[]} [options.levels] - Array of Uint8Array or other supported browser interface.
      * @example
      * // Create a 8x8x24-bit texture
      * const texture = new pc.Texture(graphicsDevice, {
diff --git a/src/platform/graphics/vertex-format.js b/src/platform/graphics/vertex-format.js
index c79204a02..ca539ec91 100644
--- a/src/platform/graphics/vertex-format.js
+++ b/src/platform/graphics/vertex-format.js
@@ -10,6 +10,7 @@ import {
 } from './constants.js';
 
 const stringIds = new StringIds();
+const webgpuValidElementSizes = [2, 4, 8, 12, 16];
 
 /**
  * A vertex format is a descriptor that defines the layout of vertex data inside a
@@ -144,7 +145,7 @@ class VertexFormat {
             elementSize = elementDesc.components * typedArrayTypesByteSize[elementDesc.type];
 
             // WebGPU has limited element size support (for example uint16x3 is not supported)
-            Debug.assert(!graphicsDevice.isWebGPU || [2, 4, 8, 12, 16].includes(elementSize),
+            Debug.assert(VertexFormat.isElementValid(graphicsDevice, elementDesc),
                          `WebGPU does not support the format of vertex element ${elementDesc.semantic} : ${vertexTypesNames[elementDesc.type]} x ${elementDesc.components}`);
 
             // align up the offset to elementSize (when vertexCount is specified only - case of non-interleaved format)
@@ -224,6 +225,13 @@ class VertexFormat {
         return VertexFormat._defaultInstancingFormat;
     }
 
+    static isElementValid(graphicsDevice, elementDesc) {
+        const elementSize = elementDesc.components * typedArrayTypesByteSize[elementDesc.type];
+        if (graphicsDevice.isWebGPU && !webgpuValidElementSizes.includes(elementSize))
+            return false;
+        return true;
+    }
+
     /**
      * Applies any changes made to the VertexFormat's properties.
      *
diff --git a/src/platform/graphics/webgl/webgl-graphics-device.js b/src/platform/graphics/webgl/webgl-graphics-device.js
index 6800b6f60..363e4053d 100644
--- a/src/platform/graphics/webgl/webgl-graphics-device.js
+++ b/src/platform/graphics/webgl/webgl-graphics-device.js
@@ -2672,43 +2672,6 @@ class WebglGraphicsDevice extends GraphicsDevice {
         this._vaoMap.clear();
     }
 
-    resizeCanvas(width, height) {
-
-        // store the client sizes in CSS pixels, without pixel ratio applied
-        this._width = width;
-        this._height = height;
-
-        const ratio = Math.min(this._maxPixelRatio, platform.browser ? window.devicePixelRatio : 1);
-        width = Math.floor(width * ratio);
-        height = Math.floor(height * ratio);
-
-        if (this.canvas.width !== width || this.canvas.height !== height) {
-
-            this.canvas.width = width;
-            this.canvas.height = height;
-
-            this.fire(GraphicsDevice.EVENT_RESIZE, width, height);
-        }
-    }
-
-    /**
-     * Width of the back buffer in pixels.
-     *
-     * @type {number}
-     */
-    get width() {
-        return this.gl.drawingBufferWidth || this.canvas.width;
-    }
-
-    /**
-     * Height of the back buffer in pixels.
-     *
-     * @type {number}
-     */
-    get height() {
-        return this.gl.drawingBufferHeight || this.canvas.height;
-    }
-
     /**
      * Fullscreen mode.
      *
diff --git a/src/platform/graphics/webgl/webgl-shader.js b/src/platform/graphics/webgl/webgl-shader.js
index c4e4c0eda..a84229e92 100644
--- a/src/platform/graphics/webgl/webgl-shader.js
+++ b/src/platform/graphics/webgl/webgl-shader.js
@@ -346,11 +346,10 @@ class WebglShader {
             if (definition.attributes[info.name] === undefined) {
                 console.error(`Vertex shader attribute "${info.name}" is not mapped to a semantic in shader definition, shader [${shader.label}]`, shader);
                 shader.failed = true;
+            } else {
+                const shaderInput = new WebglShaderInput(device, definition.attributes[info.name], device.pcUniformType[info.type], location);
+                this.attributes.push(shaderInput);
             }
-
-            const shaderInput = new WebglShaderInput(device, definition.attributes[info.name], device.pcUniformType[info.type], location);
-
-            this.attributes.push(shaderInput);
         }
 
         // Query the program for each shader state (GLSL 'uniform')
diff --git a/src/platform/graphics/webgpu/webgpu-graphics-device.js b/src/platform/graphics/webgpu/webgpu-graphics-device.js
index 407138b60..7d9e2a772 100644
--- a/src/platform/graphics/webgpu/webgpu-graphics-device.js
+++ b/src/platform/graphics/webgpu/webgpu-graphics-device.js
@@ -163,29 +163,13 @@ class WebgpuGraphicsDevice extends GraphicsDevice {
         // temporary message to confirm Webgpu is being used
         Debug.log("WebgpuGraphicsDevice initialization ..");
 
-        const loadScript = (url) => {
-            return new Promise(function (resolve, reject) {
-                const script = document.createElement('script');
-                script.src = url;
-                script.async = false;
-                script.onload = function () {
-                    resolve(url);
-                };
-                script.onerror = function () {
-                    reject(new Error(`Failed to download script ${url}`));
-                };
-                document.body.appendChild(script);
-            });
-        };
+        const results = await Promise.all([
+            import(twgslUrl).then(module => twgsl(twgslUrl.replace('.js', '.wasm'))),
+            import(glslangUrl).then(module => module.default())
+        ]);
 
-        // TODO: add both loadScript calls and requestAdapter to promise list and wait for all.
-        await loadScript(glslangUrl);
-        await loadScript(twgslUrl);
-
-        this.glslang = await glslang();
-
-        const wasmPath = twgslUrl.replace('.js', '.wasm');
-        this.twgsl = await twgsl(wasmPath);
+        this.twgsl = results[0];
+        this.glslang = results[1];
 
         /** @type {GPURequestAdapterOptions} */
         const adapterOptions = {
@@ -243,9 +227,6 @@ class WebgpuGraphicsDevice extends GraphicsDevice {
 
         this.initDeviceCaps();
 
-        // initially fill the window. This needs improvement.
-        this.setResolution(window.innerWidth, window.innerHeight);
-
         this.gpuContext = this.canvas.getContext('webgpu');
 
         // pixel format of the framebuffer is the most efficient one on the system
@@ -305,18 +286,6 @@ class WebgpuGraphicsDevice extends GraphicsDevice {
         });
     }
 
-    resizeCanvas(width, height) {
-
-        this._width = width;
-        this._height = height;
-
-        if (this.canvas.width !== width || this.canvas.height !== height) {
-            this.canvas.width = width;
-            this.canvas.height = height;
-            this.fire(GraphicsDevice.EVENT_RESIZE, width, height);
-        }
-    }
-
     frameStart() {
 
         super.frameStart();
@@ -680,14 +649,6 @@ class WebgpuGraphicsDevice extends GraphicsDevice {
         }
     }
 
-    get width() {
-        return this._width;
-    }
-
-    get height() {
-        return this._height;
-    }
-
     setDepthBias(on) {
     }
 
diff --git a/src/platform/graphics/webgpu/webgpu-texture.js b/src/platform/graphics/webgpu/webgpu-texture.js
index c5cb74e8b..23dfe004b 100644
--- a/src/platform/graphics/webgpu/webgpu-texture.js
+++ b/src/platform/graphics/webgpu/webgpu-texture.js
@@ -67,6 +67,10 @@ gpuFilterModes[FILTER_NEAREST_MIPMAP_LINEAR] = { level: 'nearest', mip: 'linear'
 gpuFilterModes[FILTER_LINEAR_MIPMAP_NEAREST] = { level: 'linear', mip: 'nearest' };
 gpuFilterModes[FILTER_LINEAR_MIPMAP_LINEAR] = { level: 'linear', mip: 'linear' };
 
+const dummyUse = (thingOne) => {
+    // so lint thinks we're doing something with thingOne
+};
+
 /**
  * A WebGPU implementation of the Texture.
  *
@@ -411,6 +415,9 @@ class WebgpuTexture {
         // submit existing scheduled commands to the queue before copying to preserve the order
         device.submit();
 
+        // create 2d context so webgpu can upload the texture
+        dummyUse(image instanceof HTMLCanvasElement && image.getContext('2d'));
+
         Debug.trace(TRACEID_RENDER_QUEUE, `IMAGE-TO-TEX: mip:${mipLevel} face:${face} ${this.texture.name}`);
         device.wgpu.queue.copyExternalImageToTexture(src, dst, copySize);
     }
diff --git a/src/scene/graphics/post-effect.js b/src/scene/graphics/post-effect.js
index 2231a860d..69ba9ae1d 100644
--- a/src/scene/graphics/post-effect.js
+++ b/src/scene/graphics/post-effect.js
@@ -67,10 +67,10 @@ class PostEffect {
     /**
      * Draw a screen-space rectangle in a render target, using a specified shader.
      *
-     * @param {import('../../platform/graphics/render-target.js').RenderTarget} target - The output
-     * render target.
-     * @param {import('../../platform/graphics/shader.js').Shader} shader - The shader to be used for
-     * drawing the rectangle.
+     * @param {import('../../platform/graphics/render-target.js').RenderTarget|null} target - The
+     * output render target.
+     * @param {import('../../platform/graphics/shader.js').Shader} shader - The shader to be used
+     * for drawing the rectangle.
      * @param {import('../../core/math/vec4.js').Vec4} [rect] - The normalized screen-space position
      * (rect.x, rect.y) and size (rect.z, rect.w) of the rectangle. Default is [0, 0, 1, 1].
      */
diff --git a/src/scene/graphics/quad-render-utils.js b/src/scene/graphics/quad-render-utils.js
index 06169bb89..c0ac12ab6 100644
--- a/src/scene/graphics/quad-render-utils.js
+++ b/src/scene/graphics/quad-render-utils.js
@@ -14,7 +14,7 @@ const _tempRect = new Vec4();
  *
  * @param {import('../../platform/graphics/graphics-device.js').GraphicsDevice} device - The graphics device used to draw
  * the quad.
- * @param {import('../../platform/graphics/render-target.js').RenderTarget|undefined} target - The destination render
+ * @param {import('../../platform/graphics/render-target.js').RenderTarget|null} target - The destination render
  * target. If undefined, target is the frame buffer.
  * @param {import('../../platform/graphics/shader.js').Shader} shader - The shader used for rendering the quad. Vertex
  * shader should contain `attribute vec2 vertex_position`.
@@ -67,10 +67,8 @@ function drawQuadWithShader(device, target, shader, rect, scissorRect) {
     // in a separate render pass B (e.g. rendering UI). Those two render passes need to be merged into one, as they both render into
     // the same framebuffer. The workaround here is to store multi-sampled color buffer, instead of only resolving it, which is wasted
     // memory bandwidth. Without this we end up with a black result (or just UI), as multi-sampled color buffer is never written to.
-    if (device.isWebGPU && target === null) {
-        const samples = target?.samples ?? device.samples;
-        if (samples > 1)
-            renderPass.colorOps.store = true;
+    if (device.isWebGPU && target === null && device.samples > 1) {
+        renderPass.colorOps.store = true;
     }
 
     renderPass.render();
diff --git a/src/scene/mesh.js b/src/scene/mesh.js
index 0f08bd2fd..a43249c29 100644
--- a/src/scene/mesh.js
+++ b/src/scene/mesh.js
@@ -723,7 +723,9 @@ class Mesh extends RefCountedObject {
             } else {
                 // destination data is array
                 indices.length = 0;
-                indices.push(streamIndices);
+                for (let i = 0, il = streamIndices.length; i < il; i++) {
+                    indices.push(streamIndices[i]);
+                }
             }
         } else {
             // get data from IndexBuffer
diff --git a/utils/rollup-import-validation.mjs b/utils/rollup-import-validation.mjs
new file mode 100644
index 000000000..6f0698678
--- /dev/null
+++ b/utils/rollup-import-validation.mjs
@@ -0,0 +1,58 @@
+import path from 'node:path';
+
+/** @typedef {import('rollup').Plugin} Plugin */
+
+/**
+ * Validate and print warning if an engine module on a lower level imports module on a higher level
+ *
+ * @param {string} rootFile - The root file, typically `src/index.js`.
+ * @param {boolean} enable - Enable or disable the plugin.
+ * @returns {Plugin} The plugin.
+ */
+export function engineLayerImportValidation(rootFile, enable) {
+
+    const folderLevels = {
+        'core': 0,
+        'platform': 1,
+        'scene': 2,
+        'framework': 3
+    };
+
+    let rootPath;
+
+    return {
+        name: 'engineLayerImportValidation',
+
+        buildStart() {
+            rootPath = path.parse(path.resolve(rootFile)).dir;
+        },
+
+        resolveId(imported, importer) {
+            if (enable) {
+
+                // skip non-relative paths, those are not our imports, for example 'rollupPluginBabelHelpers.js'
+                if (importer && imported && imported.includes('./')) {
+
+                    // convert importer path
+                    const importerDir = path.parse(importer).dir;
+                    const relImporter = path.dirname(path.relative(rootPath, importer));
+                    const folderImporter = relImporter.split(path.sep)[0];
+                    const levelImporter = folderLevels[folderImporter];
+
+                    // convert imported path
+                    const absImported = path.resolve(path.join(importerDir, imported));
+                    const relImported = path.dirname(path.relative(rootPath, absImported));
+                    const folderImported = relImported.split(path.sep)[0];
+                    const levelImported = folderLevels[folderImported];
+
+                    if (levelImporter < levelImported) {
+                        console.log(`(!) Incorrect import: [${path.relative(rootPath, importer)}] -> [${imported}]`);
+                    }
+                }
+            }
+
+            // we don't process imports, return null to allow chaining
+            return null;
+        }
+    };
+}
diff --git a/utils/rollup-spaces-to-tabs.mjs b/utils/rollup-spaces-to-tabs.mjs
new file mode 100644
index 000000000..19d205f5f
--- /dev/null
+++ b/utils/rollup-spaces-to-tabs.mjs
@@ -0,0 +1,35 @@
+import { createFilter } from '@rollup/pluginutils';
+
+/** @typedef {import('rollup').Plugin} Plugin */
+
+/**
+ * This plugin converts every two spaces into one tab. Two spaces is the default the babel plugin
+ * outputs, which is independent of the four spaces of the code base.
+ *
+ * @param {boolean} enable - Enable or disable the plugin.
+ * @returns {Plugin} The plugin.
+ */
+export function spacesToTabs(enable) {
+    const filter = createFilter([
+        '**/*.js'
+    ], []);
+
+    return {
+        name: "spacesToTabs",
+        transform(code, id) {
+            if (!enable || !filter(id)) return undefined;
+            // ^    = start of line
+            // " +" = one or more spaces
+            // gm   = find all + multiline
+            const regex = /^ +/gm;
+            code = code.replace(
+                regex,
+                startSpaces => startSpaces.replace(/ {2}/g, '\t')
+            );
+            return {
+                code,
+                map: null
+            };
+        }
+    };
+}


Test patch:
diff --git a/test/core/math/vec2.test.mjs b/test/core/math/vec2.test.mjs
index 843f66a2c..9aa91803b 100644
--- a/test/core/math/vec2.test.mjs
+++ b/test/core/math/vec2.test.mjs
@@ -76,6 +76,17 @@ describe('Vec2', function () {
 
     });
 
+    describe('#addScaled', function () {
+
+        it('adds a scaled vector', function () {
+            const v = new Vec2(1, 2);
+            v.addScaled(Vec2.UP, 2);
+            expect(v.x).to.equal(1);
+            expect(v.y).to.equal(4);
+        });
+
+    });
+
     describe('#ceil', function () {
 
         it('leaves integers unchanged', function () {
diff --git a/test/core/math/vec3.test.mjs b/test/core/math/vec3.test.mjs
index f0270b943..6678dbf3f 100644
--- a/test/core/math/vec3.test.mjs
+++ b/test/core/math/vec3.test.mjs
@@ -84,6 +84,18 @@ describe('Vec3', function () {
 
     });
 
+    describe('#addScaled', function () {
+
+        it('adds a scaled vector', function () {
+            const v = new Vec3(1, 2, 3);
+            v.addScaled(Vec3.UP, 2);
+            expect(v.x).to.equal(1);
+            expect(v.y).to.equal(4);
+            expect(v.z).to.equal(3);
+        });
+
+    });
+
     describe('#ceil', function () {
 
         it('leaves integers unchanged', function () {
diff --git a/test/core/math/vec4.test.mjs b/test/core/math/vec4.test.mjs
index 5b8dfa40f..166ddbaae 100644
--- a/test/core/math/vec4.test.mjs
+++ b/test/core/math/vec4.test.mjs
@@ -92,6 +92,19 @@ describe('Vec4', function () {
 
     });
 
+    describe('#addScaled', function () {
+
+        it('adds a scaled vector', function () {
+            const v = new Vec4(1, 2, 3, 4);
+            v.addScaled(Vec4.ONE, 2);
+            expect(v.x).to.equal(3);
+            expect(v.y).to.equal(4);
+            expect(v.z).to.equal(5);
+            expect(v.w).to.equal(6);
+        });
+
+    });
+
     describe('#ceil', function () {
 
         it('leaves integers unchanged', function () {