Repository: playcanvas/engine
Pull request #7661 (2025-05-09 23:38:31)

docs: Document i18n's `change` event
## Description

Formalizes the `set:locale` event on the `app.i18n` API.

This is helpful for updating your 3D scene in reaction to locale changes.

i18n already has an event that it triggers on locale change, `set:locale`, it is just not public API yet.

## Relevant Discussion

Discord: https://discord.com/channels/408617316415307776/408617316415307778/1370523328074219571

> 
> Chris Hayes
> **There's no way to figure out what events an API can trigger without just looking at the engine code right?**
> 
> **ie—with i18n, I'd like to make a handler for when the locale is changed, but there's no event list documented. Looking at the engine source code, it looks like set:locale is the event I want.**
> [I18n | Engine API Reference - v2.7.4](https://api.playcanvas.com/engine/classes/I18n.html)
>
> yaustar
> **If it's not in the docs, it's not public API and therefore subject to change without warning**
> **When would the locale change without you setting it yourself?**
>
> Chris Hayes
> **I mean I do set it myself, but I figured .i18n would fire an event when locale changed since it has a setter.**
> **I can always just make my own event, but figured I would check if there was a built-in one first.**
>
> yaustar
> **You can also fire the event on i18n object too**
>
> Chris Hayes
> **yeah true**
>
> yaustar
> **That way you know it will work for sure between engine updates**
>
> Chris Hayes
> **It would be a nice event to formalize in the engine though, I can see a lot of situations where you'd want to update your scene if the locale changes, rather than just using the last fetched .locale value.**
>
> yaustar
> **(or you can do a PR to make it public API 😄)**

## Misc

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).


Maksims: Do you think `change` - for the name of this event would be better here? `set:locale` - seems over specific, while being already on a very specific class.
Christopher-Hayes: It _would_ be similar to `Asset.EVENT_CHANGE = 'change'`

https://api.playcanvas.com/engine/classes/Asset.html#event_change

That would make sense to me. I searched the forum and google search for people using `set:locale` and nothing is coming up, so I expect that name change would break very few projects.
Maksims: If it wasn't public API, then changing it should not be a problem at all.

Patch:
diff --git a/src/framework/asset/asset-localized.js b/src/framework/asset/asset-localized.js
index 088957d10..d6d386615 100644
--- a/src/framework/asset/asset-localized.js
+++ b/src/framework/asset/asset-localized.js
@@ -1,4 +1,5 @@
 import { EventHandler } from '../../core/event-handler.js';
+import { I18n } from '../i18n/i18n.js';
 
 import { Asset } from './asset.js';
 
@@ -7,7 +8,7 @@ class LocalizedAsset extends EventHandler {
         super();
 
         this._app = app;
-        app.i18n.on('set:locale', this._onSetLocale, this);
+        app.i18n.on(I18n.EVENT_CHANGE, this._onSetLocale, this);
 
         this._autoLoad = false;
         this._disableLocalization = false;
@@ -223,7 +224,7 @@ class LocalizedAsset extends EventHandler {
 
     destroy() {
         this.defaultAsset = null;
-        this._app.i18n.off('set:locale', this._onSetLocale, this);
+        this._app.i18n.off(I18n.EVENT_CHANGE, this._onSetLocale, this);
         this.off();
     }
 }
diff --git a/src/framework/components/element/text-element.js b/src/framework/components/element/text-element.js
index 95df5f597..da9c5a11c 100644
--- a/src/framework/components/element/text-element.js
+++ b/src/framework/components/element/text-element.js
@@ -11,6 +11,7 @@ import { MeshInstance } from '../../../scene/mesh-instance.js';
 import { Model } from '../../../scene/model.js';
 import { Mesh } from '../../../scene/mesh.js';
 import { LocalizedAsset } from '../../asset/asset-localized.js';
+import { I18n } from '../../i18n/i18n.js';
 import { FONT_BITMAP, FONT_MSDF } from '../../font/constants.js';
 import { Markup } from './markup.js';
 
@@ -213,7 +214,7 @@ class TextElement {
         element.on('set:draworder', this._onDrawOrderChange, this);
         element.on('set:pivot', this._onPivotChange, this);
 
-        this._system.app.i18n.on('set:locale', this._onLocaleSet, this);
+        this._system.app.i18n.on(I18n.EVENT_CHANGE, this._onLocaleSet, this);
         this._system.app.i18n.on('data:add', this._onLocalizationData, this);
         this._system.app.i18n.on('data:remove', this._onLocalizationData, this);
 
@@ -240,7 +241,7 @@ class TextElement {
         this._element.off('set:draworder', this._onDrawOrderChange, this);
         this._element.off('set:pivot', this._onPivotChange, this);
 
-        this._system.app.i18n.off('set:locale', this._onLocaleSet, this);
+        this._system.app.i18n.off(I18n.EVENT_CHANGE, this._onLocaleSet, this);
         this._system.app.i18n.off('data:add', this._onLocalizationData, this);
         this._system.app.i18n.off('data:remove', this._onLocalizationData, this);
     }
diff --git a/src/framework/i18n/i18n.js b/src/framework/i18n/i18n.js
index 50dd6a81c..df9900e42 100644
--- a/src/framework/i18n/i18n.js
+++ b/src/framework/i18n/i18n.js
@@ -14,6 +14,17 @@ import { I18nParser } from './i18n-parser.js';
  * implementation for {@link I18n#getText} and {@link I18n#getPluralText}.
  */
 class I18n extends EventHandler {
+    /**
+     * Fired when when the locale is changed.
+     *
+     * @event
+     * @example
+     * app.i18n.on('change', (newLocale, oldLocale) => {
+     *    console.log(`Locale changed from ${oldLocale} to ${newLocale}`);
+     * });
+     */
+    static EVENT_CHANGE = 'change';
+
     /**
      * Create a new I18n instance.
      *
@@ -114,7 +125,7 @@ class I18n extends EventHandler {
         this._pluralFn = getPluralFn(this._lang);
 
         // raise event
-        this.fire('set:locale', value, old);
+        this.fire(I18n.EVENT_CHANGE, value, old);
     }
 
     /**
diff --git a/src/framework/parsers/sogs.js b/src/framework/parsers/sogs.js
index 73b25bd3e..796526dd7 100644
--- a/src/framework/parsers/sogs.js
+++ b/src/framework/parsers/sogs.js
@@ -27,7 +27,7 @@ class SogsParser {
     async loadTextures(url, callback, asset, meta) {
         const { assets } = this.app;
 
-        const subs = ['means', 'opacities', 'quats', 'scales', 'sh0', 'shN'];
+        const subs = ['means', 'quats', 'scales', 'sh0', 'shN'];
 
         const textures = {};
         const promises = [];
@@ -58,29 +58,23 @@ class SogsParser {
         // wait for all textures to complete loading
         await Promise.allSettled(promises);
 
-        // sh palette has 64 sh entries per row. use width to calculate number of bands
-        const widths = {
-            192: 1,     // 64 * 3
-            512: 2,     // 64 * 8
-            960: 3      // 64 * 15
-        };
-
         // construct the gsplat resource
         const data = new GSplatSogsData();
         data.meta = meta;
         data.numSplats = meta.means.shape[0];
-        data.shBands = widths[textures.shN?.[0]?.resource?.width] ?? 0;
         data.means_l = textures.means[0].resource;
         data.means_u = textures.means[1].resource;
-        data.opacities = textures.opacities[0].resource;
         data.quats = textures.quats[0].resource;
         data.scales = textures.scales[0].resource;
         data.sh0 = textures.sh0[0].resource;
         data.sh_centroids = textures.shN?.[0]?.resource;
-        data.sh_labels_l = textures.shN?.[1]?.resource;
-        data.sh_labels_u = textures.shN?.[2]?.resource;
+        data.sh_labels = textures.shN?.[1]?.resource;
+
+        if (asset.data?.reorder ?? true) {
+            await data.reorderData();
+        }
 
-        const resource = new GSplatResource(this.app, (asset.data?.decompress) ? data.decompress() : data, []);
+        const resource = new GSplatResource(this.app, (asset.data?.decompress) ? (await data.decompress()) : data, []);
 
         callback(null, resource);
     }
diff --git a/src/index.js b/src/index.js
index b5f56df79..469515fb7 100644
--- a/src/index.js
+++ b/src/index.js
@@ -236,6 +236,7 @@ export { Sky } from './scene/skybox/sky.js';
 export { GSplatData } from './scene/gsplat/gsplat-data.js';
 export { GSplat } from './scene/gsplat/gsplat.js';
 export { GSplatInstance } from './scene/gsplat/gsplat-instance.js';
+export { GSplatSogsData } from './scene/gsplat/gsplat-sogs-data.js';
 
 // FRAMEWORK
 export * from './framework/constants.js';
diff --git a/src/scene/gsplat/gsplat-data.js b/src/scene/gsplat/gsplat-data.js
index 7b92ca995..0eee795fc 100644
--- a/src/scene/gsplat/gsplat-data.js
+++ b/src/scene/gsplat/gsplat-data.js
@@ -392,9 +392,9 @@ class GSplatData {
 
         const codes = new Map();
         for (let i = 0; i < this.numSplats; i++) {
-            const ix = Math.floor((x[i] - minX) * sizeX);
-            const iy = Math.floor((y[i] - minY) * sizeY);
-            const iz = Math.floor((z[i] - minZ) * sizeZ);
+            const ix = Math.min(1023, Math.floor((x[i] - minX) * sizeX));
+            const iy = Math.min(1023, Math.floor((y[i] - minY) * sizeY));
+            const iz = Math.min(1023, Math.floor((z[i] - minZ) * sizeZ));
             const code = encodeMorton3(ix, iy, iz);
 
             const val = codes.get(code);
diff --git a/src/scene/gsplat/gsplat-sogs-data.js b/src/scene/gsplat/gsplat-sogs-data.js
index bd4c1c3bc..5fd5092b4 100644
--- a/src/scene/gsplat/gsplat-sogs-data.js
+++ b/src/scene/gsplat/gsplat-sogs-data.js
@@ -2,21 +2,55 @@ import { Quat } from '../../core/math/quat.js';
 import { Vec3 } from '../../core/math/vec3.js';
 import { Vec4 } from '../../core/math/vec4.js';
 import { GSplatData } from './gsplat-data.js';
+import { BlendState } from '../../platform/graphics/blend-state.js';
+import { DepthState } from '../../platform/graphics/depth-state.js';
+import { RenderTarget } from '../../platform/graphics/render-target.js';
+import { Texture } from '../../platform/graphics/texture.js';
+import { CULLFACE_NONE, PIXELFORMAT_R32U, PIXELFORMAT_RGBA8, SEMANTIC_POSITION } from '../../platform/graphics/constants.js';
+import { drawQuadWithShader } from '../../scene/graphics/quad-render-utils.js';
+import { createShaderFromCode } from '../shader-lib/shader-utils.js';
 
-let offscreen = null;
-let ctx = null;
+const SH_C0 = 0.28209479177387814;
 
-const readImageData = (imageBitmap) => {
-    if (!offscreen || offscreen.width !== imageBitmap.width || offscreen.height !== imageBitmap.height) {
-        offscreen = new OffscreenCanvas(imageBitmap.width, imageBitmap.height);
-        ctx = offscreen.getContext('2d');
-        ctx.globalCompositeOperation = 'copy';
+const reorderVS = /* glsl */`
+    attribute vec2 vertex_position;
+    void main(void) {
+        gl_Position = vec4(vertex_position, 0.0, 1.0);
     }
-    ctx.drawImage(imageBitmap, 0, 0);
-    return ctx.getImageData(0, 0, imageBitmap.width, imageBitmap.height).data;
+`;
+
+const reorderFS = /* glsl */`
+    uniform usampler2D orderTexture;
+    uniform sampler2D sourceTexture;
+    uniform highp uint numSplats;
+
+    void main(void) {
+        uint w = uint(textureSize(sourceTexture, 0).x);
+        uint idx = uint(gl_FragCoord.x) + uint(gl_FragCoord.y) * w;
+        if (idx >= numSplats) discard;
+
+        // fetch the source index and calculate source uv
+        uint sidx = texelFetch(orderTexture, ivec2(gl_FragCoord.xy), 0).x;
+        uvec2 suv = uvec2(sidx % w, sidx / w);
+
+        // sample the source texture
+        gl_FragColor = texelFetch(sourceTexture, ivec2(suv), 0);
+    }
+`;
+
+const readImageDataAsync = (texture) => {
+    return texture.read(0, 0, texture.width, texture.height, {
+        mipLevel: 0,
+        face: 0,
+        immediate: true
+    });
 };
 
-const SH_C0 = 0.28209479177387814;
+const resolve = (scope, values) => {
+    for (const key in values) {
+        scope.resolve(key).setValue(values[key]);
+    }
+};
 
 class GSplatSogsIterator {
     constructor(data, p, r, s, c, sh) {
@@ -25,16 +59,16 @@ class GSplatSogsIterator {
 
         // extract means for centers
         const { meta } = data;
-        const { means, quats, scales, opacities, sh0, shN } = meta;
-        const means_l_data = p && readImageData(data.means_l._levels[0]);
-        const means_u_data = p && readImageData(data.means_u._levels[0]);
-        const quats_data = r && readImageData(data.quats._levels[0]);
-        const scales_data = s && readImageData(data.scales._levels[0]);
-        const opacities_data = c && readImageData(data.opacities._levels[0]);
-        const sh0_data = c && readImageData(data.sh0._levels[0]);
-        const sh_labels_l_data = sh && readImageData(data.sh_labels_l._levels[0]);
-        const sh_labels_u_data = sh && readImageData(data.sh_labels_u._levels[0]);
-        const sh_centroids_data = sh && readImageData(data.sh_centroids._levels[0]);
+        const { means, scales, sh0, shN } = meta;
+        const means_l_data = p && data.means_l._levels[0];
+        const means_u_data = p && data.means_u._levels[0];
+        const quats_data = r && data.quats._levels[0];
+        const scales_data = s && data.scales._levels[0];
+        const sh0_data = c && data.sh0._levels[0];
+        const sh_labels_data = sh && data.sh_labels._levels[0];
+        const sh_centroids_data = sh && data.sh_centroids._levels[0];
+
+        const norm = 2.0 / Math.sqrt(2.0);
 
         this.read = (i) => {
             if (p) {
@@ -48,11 +82,18 @@ class GSplatSogsIterator {
             }
 
             if (r) {
-                const qx = lerp(quats.mins[0], quats.maxs[0], quats_data[i * 4 + 0] / 255);
-                const qy = lerp(quats.mins[1], quats.maxs[1], quats_data[i * 4 + 1] / 255);
-                const qz = lerp(quats.mins[2], quats.maxs[2], quats_data[i * 4 + 2] / 255);
-                const qw = Math.sqrt(Math.max(0, 1 - (qx * qx + qy * qy + qz * qz)));
-                r.set(qy, qz, qw, qx);
+                const a = (quats_data[i * 4 + 0] / 255 - 0.5) * norm;
+                const b = (quats_data[i * 4 + 1] / 255 - 0.5) * norm;
+                const c = (quats_data[i * 4 + 2] / 255 - 0.5) * norm;
+                const d = Math.sqrt(Math.max(0, 1 - (a * a + b * b + c * c)));
+                const mode = quats_data[i * 4 + 3] - 252;
+
+                switch (mode) {
+                    case 0: r.set(a, b, c, d); break;
+                    case 1: r.set(d, b, c, a); break;
+                    case 2: r.set(b, d, c, a); break;
+                    case 3: r.set(b, c, d, a); break;
+                }
             }
 
             if (s) {
@@ -66,18 +107,18 @@ class GSplatSogsIterator {
                 const r = lerp(sh0.mins[0], sh0.maxs[0], sh0_data[i * 4 + 0] / 255);
                 const g = lerp(sh0.mins[1], sh0.maxs[1], sh0_data[i * 4 + 1] / 255);
                 const b = lerp(sh0.mins[2], sh0.maxs[2], sh0_data[i * 4 + 2] / 255);
-                const a = lerp(opacities.mins[0], opacities.maxs[0], opacities_data[i * 4 + 0] / 255);
+                const a = lerp(sh0.mins[3], sh0.maxs[3], sh0_data[i * 4 + 3] / 255);
 
                 c.set(
                     0.5 + r * SH_C0,
                     0.5 + g * SH_C0,
                     0.5 + b * SH_C0,
-                    1.0 / (1.0 + Math.exp(a * -1.0))
+                    1.0 / (1.0 + Math.exp(-a))
                 );
             }
 
             if (sh) {
-                const n = sh_labels_l_data[i * 4 + 0] + (sh_labels_u_data[i * 4 + 0] << 8);
+                const n = sh_labels_data[i * 4 + 0] + (sh_labels_data[i * 4 + 1] << 8);
                 const u = (n % 64) * 15;
                 const v = Math.floor(n / 64);
 
@@ -96,8 +137,6 @@ class GSplatSogsData {
 
     numSplats;
 
-    shBands;
-
     means_l;
 
     means_u;
@@ -106,15 +145,14 @@ class GSplatSogsData {
 
     scales;
 
-    opacities;
-
     sh0;
 
     sh_centroids;
 
-    sh_labels_l;
+    sh_labels;
 
-    sh_labels_u;
+    // if data is reordered at load, this texture stores the reorder indices.
+    orderTexture;
 
     createIter(p, r, s, c, sh) {
         return new GSplatSogsIterator(this, p, r, s, c, sh);
@@ -141,9 +179,10 @@ class GSplatSogsData {
     getCenters(result) {
         const p = new Vec3();
         const iter = this.createIter(p);
+        const order = this.orderTexture?._levels[0];
 
         for (let i = 0; i < this.numSplats; i++) {
-            iter.read(i);
+            iter.read(order ? order[i] : i);
 
             result[i * 3 + 0] = p.x;
             result[i * 3 + 1] = p.y;
@@ -159,7 +198,17 @@ class GSplatSogsData {
         return true;
     }
 
-    decompress() {
+    get shBands() {
+        // sh palette has 64 sh entries per row. use width to calculate number of bands
+        const widths = {
+            192: 1,     // 64 * 3
+            512: 2,     // 64 * 8
+            960: 3      // 64 * 15
+        };
+        return widths[this.sh_centroids?.resource?.width] ?? 0;
+    }
+
+    async decompress() {
         const members = [
             'x', 'y', 'z',
             'f_dc_0', 'f_dc_1', 'f_dc_2', 'opacity',
@@ -169,6 +218,16 @@ class GSplatSogsData {
 
         const { shBands } = this;
 
+        // copy back gpu texture data so cpu iterator has access to it
+        const { means_l, means_u, quats, scales, sh0, sh_labels, sh_centroids } = this;
+        means_l._levels[0] = await readImageDataAsync(means_l);
+        means_u._levels[0] = await readImageDataAsync(means_u);
+        quats._levels[0] = await readImageDataAsync(quats);
+        scales._levels[0] = await readImageDataAsync(scales);
+        sh0._levels[0] = await readImageDataAsync(sh0);
+        sh_labels._levels[0] = await readImageDataAsync(sh_labels);
+        sh_centroids._levels[0] = await readImageDataAsync(sh_centroids);
+
         // allocate spherical harmonics data
         if (shBands > 0) {
             const shMembers = [];
@@ -234,6 +293,123 @@ class GSplatSogsData {
             })
         }]);
     }
+
+    // reorder the sogs texture data in gpu memory given the ordering encoded in texture data
+    reorderGpuMemory() {
+        const { orderTexture, numSplats } = this;
+        const { device, height, width } = orderTexture;
+        const { scope } = device;
+
+        const shader = createShaderFromCode(device, reorderVS, reorderFS, 'reorderShader', {
+            vertex_position: SEMANTIC_POSITION
+        });
+
+        let targetTexture = new Texture(device, {
+            width: width,
+            height: height,
+            format: PIXELFORMAT_RGBA8,
+            mipmaps: false
+        });
+
+        const members = ['means_l', 'means_u', 'quats', 'scales', 'sh0', 'sh_labels'];
+
+        device.setBlendState(BlendState.NOBLEND);
+        device.setCullMode(CULLFACE_NONE);
+        device.setDepthState(DepthState.NODEPTH);
+
+        members.forEach((member) => {
+            const sourceTexture = this[member];
+
+            const renderTarget = new RenderTarget({
+                colorBuffer: targetTexture,
+                depth: false,
+                mipLevel: 0
+            });
+
+            resolve(scope, {
+                orderTexture,
+                sourceTexture,
+                numSplats
+            });
+
+            drawQuadWithShader(device, renderTarget, shader);
+
+            this[member] = targetTexture;
+            targetTexture.name = sourceTexture.name;
+            targetTexture._levels = sourceTexture._levels;
+            sourceTexture._levels = [];
+            targetTexture = sourceTexture;
+
+            renderTarget.destroy();
+        });
+
+        targetTexture.destroy();
+        shader.destroy();
+    }
+
+    // construct an array containing the Morton order of the splats
+    // returns an array of 32-bit unsigned integers
+    calcMortonOrder() {
+        // https://fgiesen.wordpress.com/2009/12/13/decoding-morton-codes/
+        const encodeMorton3 = (x, y, z) => {
+            const Part1By2 = (x) => {
+                x &= 0x000003ff;
+                x = (x ^ (x << 16)) & 0xff0000ff;
+                x = (x ^ (x <<  8)) & 0x0300f00f;
+                x = (x ^ (x <<  4)) & 0x030c30c3;
+                x = (x ^ (x <<  2)) & 0x09249249;
+                return x;
+            };
+
+            return (Part1By2(z) << 2) + (Part1By2(y) << 1) + Part1By2(x);
+        };
+
+        const { means_l, means_u } = this;
+        const means_l_data = means_l._levels[0];
+        const means_u_data = means_u._levels[0];
+        const codes = new BigUint64Array(this.numSplats);
+
+        // generate Morton codes for each splat based on the means directly (i.e. the log-space coordinates)
+        for (let i = 0; i < this.numSplats; ++i) {
+            const ix = (means_u_data[i * 4 + 0] << 2) | (means_l_data[i * 4 + 0] >>> 6);
+            const iy = (means_u_data[i * 4 + 1] << 2) | (means_l_data[i * 4 + 1] >>> 6);
+            const iz = (means_u_data[i * 4 + 2] << 2) | (means_l_data[i * 4 + 2] >>> 6);
+            codes[i] = BigInt(encodeMorton3(ix, iy, iz)) << BigInt(32) | BigInt(i);
+        }
+
+        codes.sort();
+
+        // allocate data for the order buffer, but make it texture-memory sized
+        const order = new Uint32Array(means_l.width * means_l.height);
+        for (let i = 0; i < this.numSplats; ++i) {
+            order[i] = Number(codes[i] & BigInt(0xffffffff));
+        }
+
+        return order;
+    }
+
+    async reorderData() {
+        const { device, height, width } = this.means_l;
+
+        // copy back means_l and means_u data from gpu so cpu reorder has access to it
+        this.means_l._levels[0] = await readImageDataAsync(this.means_l);
+        this.means_u._levels[0] = await readImageDataAsync(this.means_u);
+
+        this.orderTexture = new Texture(device, {
+            name: 'orderTexture',
+            width,
+            height,
+            format: PIXELFORMAT_R32U,
+            mipmaps: false,
+            levels: [this.calcMortonOrder()]
+        });
+
+        device.on('devicerestored', () => {
+            this.reorderGpuMemory();
+        });
+
+        this.reorderGpuMemory();
+    }
 }
 
 export { GSplatSogsData };
diff --git a/src/scene/gsplat/gsplat-sogs.js b/src/scene/gsplat/gsplat-sogs.js
index cac46a325..2486b5133 100644
--- a/src/scene/gsplat/gsplat-sogs.js
+++ b/src/scene/gsplat/gsplat-sogs.js
@@ -42,11 +42,11 @@ class GSplatSogs {
         result.setDefine('GSPLAT_SOGS_DATA', true);
         result.setDefine('SH_BANDS', this.gsplatData.shBands);
 
-        ['means_l', 'means_u', 'quats', 'scales', 'opacities', 'sh0', 'sh_centroids', 'sh_labels_u', 'sh_labels_l'].forEach((name) => {
+        ['means_l', 'means_u', 'quats', 'scales', 'sh0', 'sh_centroids', 'sh_labels'].forEach((name) => {
             result.setParameter(name, gsplatData[name]);
         });
 
-        ['means', 'quats', 'scales', 'opacities', 'sh0', 'shN'].forEach((name) => {
+        ['means', 'scales', 'sh0', 'shN'].forEach((name) => {
             const v = gsplatData.meta[name];
             if (v) {
                 result.setParameter(`${name}_mins`, v.mins);
diff --git a/src/scene/shader-lib/chunks-glsl/gsplat/vert/gsplatCommon.js b/src/scene/shader-lib/chunks-glsl/gsplat/vert/gsplatCommon.js
index 1a7322693..54bd741ef 100644
--- a/src/scene/shader-lib/chunks-glsl/gsplat/vert/gsplatCommon.js
+++ b/src/scene/shader-lib/chunks-glsl/gsplat/vert/gsplatCommon.js
@@ -13,9 +13,29 @@ struct SplatCenter {
     vec3 view;          // center in view space
     vec4 proj;          // center in clip space
     mat4 modelView;     // model-view matrix
-    float projMat00;    // elememt [0][0] of the projection matrix
+    float projMat00;    // element [0][0] of the projection matrix
 };
 
+mat3 quatToMat3(vec4 R) {
+    vec4 R2 = R + R;
+    float X = R2.x * R.w;
+    vec4 Y  = R2.y * R;
+    vec4 Z  = R2.z * R;
+    float W = R2.w * R.w;
+
+    return mat3(
+        1.0 - Z.z - W,
+              Y.z + X,
+              Y.w - Z.x,
+              Y.z - X,
+        1.0 - Y.y - W,
+              Z.w + Y.x,
+              Y.w + Z.x,
+              Z.w - Y.x,
+        1.0 - Y.y - Z.z
+    );
+}
+
 // stores the offset from center for the current gaussian
 struct SplatCorner {
     vec2 offset;        // corner offset from center in clip space
diff --git a/src/scene/shader-lib/chunks-glsl/gsplat/vert/gsplatCompressedData.js b/src/scene/shader-lib/chunks-glsl/gsplat/vert/gsplatCompressedData.js
index d6b765ed6..089fcb9fa 100644
--- a/src/scene/shader-lib/chunks-glsl/gsplat/vert/gsplatCompressedData.js
+++ b/src/scene/shader-lib/chunks-glsl/gsplat/vert/gsplatCompressedData.js
@@ -42,23 +42,6 @@ vec4 unpackRotation(uint bits) {
     return vec4(a, b, c, m);
 }
 
-mat3 quatToMat3(vec4 R) {
-    float x = R.x;
-    float y = R.y;
-    float z = R.z;
-    float w = R.w;
-    return mat3(
-        1.0 - 2.0 * (z * z + w * w),
-              2.0 * (y * z + x * w),
-              2.0 * (y * w - x * z),
-              2.0 * (y * z - x * w),
-        1.0 - 2.0 * (y * y + w * w),
-              2.0 * (z * w + x * y),
-              2.0 * (y * w + x * z),
-              2.0 * (z * w - x * y),
-        1.0 - 2.0 * (y * y + z * z)
-    );
-}
 
 // read center
 vec3 readCenter(SplatSource source) {
diff --git a/src/scene/shader-lib/chunks-glsl/gsplat/vert/gsplatData.js b/src/scene/shader-lib/chunks-glsl/gsplat/vert/gsplatData.js
index 653108712..e44d5f104 100644
--- a/src/scene/shader-lib/chunks-glsl/gsplat/vert/gsplatData.js
+++ b/src/scene/shader-lib/chunks-glsl/gsplat/vert/gsplatData.js
@@ -13,24 +13,6 @@ vec3 readCenter(SplatSource source) {
     return uintBitsToFloat(tA.xyz);
 }
 
-mat3 quatToMat3(vec4 R) {
-    float x = R.w;
-    float y = R.x;
-    float z = R.y;
-    float w = R.z;
-    return mat3(
-        1.0 - 2.0 * (z * z + w * w),
-              2.0 * (y * z + x * w),
-              2.0 * (y * w - x * z),
-              2.0 * (y * z - x * w),
-        1.0 - 2.0 * (y * y + w * w),
-              2.0 * (z * w + x * y),
-              2.0 * (y * w + x * z),
-              2.0 * (z * w - x * y),
-        1.0 - 2.0 * (y * y + z * z)
-    );
-}
-
 vec4 unpackRotation(vec3 packed) {
     return vec4(packed.xyz, sqrt(max(0.0, 1.0 - dot(packed, packed))));
 }
@@ -39,7 +21,7 @@ vec4 unpackRotation(vec3 packed) {
 void readCovariance(in SplatSource source, out vec3 covA, out vec3 covB) {
     vec4 tB = texelFetch(transformB, source.uv, 0);
 
-    mat3 rot = quatToMat3(unpackRotation(vec3(unpackHalf2x16(tAw), tB.w)));
+    mat3 rot = quatToMat3(unpackRotation(vec3(unpackHalf2x16(tAw), tB.w)).wxyz);
     vec3 scale = tB.xyz;
     
     // M = S * R
diff --git a/src/scene/shader-lib/chunks-glsl/gsplat/vert/gsplatSogsColor.js b/src/scene/shader-lib/chunks-glsl/gsplat/vert/gsplatSogsColor.js
index f610c52e6..2d4d28048 100644
--- a/src/scene/shader-lib/chunks-glsl/gsplat/vert/gsplatSogsColor.js
+++ b/src/scene/shader-lib/chunks-glsl/gsplat/vert/gsplatSogsColor.js
@@ -1,19 +1,13 @@
 export default /* glsl */`
 uniform mediump sampler2D sh0;
-uniform mediump sampler2D opacities;
 
-uniform vec3 sh0_mins;
-uniform vec3 sh0_maxs;
-
-uniform float opacities_mins;
-uniform float opacities_maxs;
+uniform vec4 sh0_mins;
+uniform vec4 sh0_maxs;
 
 float SH_C0 = 0.28209479177387814;
 
 vec4 readColor(in SplatSource source) {
-    vec3 clr = mix(sh0_mins, sh0_maxs, texelFetch(sh0, source.uv, 0).xyz);
-    float opacity = mix(opacities_mins, opacities_maxs, texelFetch(opacities, source.uv, 0).x);
-
-    return vec4(vec3(0.5) + clr * SH_C0, 1.0 / (1.0 + exp(opacity * -1.0)));
+    vec4 clr = mix(sh0_mins, sh0_maxs, texelFetch(sh0, source.uv, 0));
+    return vec4(vec3(0.5) + clr.xyz * SH_C0, 1.0 / (1.0 + exp(-clr.w)));
 }
 `;
diff --git a/src/scene/shader-lib/chunks-glsl/gsplat/vert/gsplatSogsData.js b/src/scene/shader-lib/chunks-glsl/gsplat/vert/gsplatSogsData.js
index e7c21be98..1f00d31d1 100644
--- a/src/scene/shader-lib/chunks-glsl/gsplat/vert/gsplatSogsData.js
+++ b/src/scene/shader-lib/chunks-glsl/gsplat/vert/gsplatSogsData.js
@@ -23,28 +23,20 @@ vec3 readCenter(SplatSource source) {
     return sign(v) * (exp(abs(v)) - 1.0);
 }
 
-mat3 quatToMat3(vec3 R) {
-    float x = R.x;
-    float y = R.y;
-    float z = R.z;
-    float w2 = clamp(1.0 - (x*x + y*y + z*z), 0.0, 1.0);
-    float w  = sqrt(w2);
-    return mat3(
-        1.0 - 2.0 * (z * z + w2),
-              2.0 * (y * z + x * w),
-              2.0 * (y * w - x * z),
-              2.0 * (y * z - x * w),
-        1.0 - 2.0 * (y * y + w2),
-              2.0 * (z * w + x * y),
-              2.0 * (y * w + x * z),
-              2.0 * (z * w - x * y),
-        1.0 - 2.0 * (y * y + z * z)
-    );
-}
+const float norm = 2.0 / sqrt(2.0);
 
 // sample covariance vectors
 void readCovariance(in SplatSource source, out vec3 covA, out vec3 covB) {
-    vec3 quat = mix(quats_mins, quats_maxs, texelFetch(quats, source.uv, 0).xyz);
+    vec4 qdata = texelFetch(quats, source.uv, 0);
+    vec3 abc = (qdata.xyz - 0.5) * norm;
+    float d = sqrt(max(0.0, 1.0 - dot(abc, abc)));
+
+    uint mode = uint(qdata.w * 255.0 + 0.5) - 252u;
+
+    vec4 quat = (mode == 0u) ? vec4(d, abc) :
+                ((mode == 1u) ? vec4(abc.x, d, abc.yz) :
+                ((mode == 2u) ? vec4(abc.xy, d, abc.z) : vec4(abc, d)));
+
     mat3 rot = quatToMat3(quat);
     vec3 scale = exp(mix(scales_mins, scales_maxs, texelFetch(scales, source.uv, 0).xyz));
 
diff --git a/src/scene/shader-lib/chunks-glsl/gsplat/vert/gsplatSogsSH.js b/src/scene/shader-lib/chunks-glsl/gsplat/vert/gsplatSogsSH.js
index 0ab7d35b3..cb7383a9e 100644
--- a/src/scene/shader-lib/chunks-glsl/gsplat/vert/gsplatSogsSH.js
+++ b/src/scene/shader-lib/chunks-glsl/gsplat/vert/gsplatSogsSH.js
@@ -1,6 +1,5 @@
 export default /* glsl */`
-uniform highp sampler2D sh_labels_u;
-uniform highp sampler2D sh_labels_l;
+uniform highp sampler2D sh_labels;
 uniform highp sampler2D sh_centroids;
 
 uniform float shN_mins;
@@ -8,9 +7,8 @@ uniform float shN_maxs;
 
 void readSHData(in SplatSource source, out vec3 sh[15], out float scale) {
     // extract spherical harmonics palette index
-    int tu = int(texelFetch(sh_labels_u, source.uv, 0).x * 255.0 * 256.0);
-    int tl = int(texelFetch(sh_labels_l, source.uv, 0).x * 255.0);
-    int n = tu + tl;
+    ivec2 t = ivec2(texelFetch(sh_labels, source.uv, 0).xy * 255.0);
+    int n = t.x + t.y * 256;
     int u = (n % 64) * 15;
     int v = n / 64;
 
diff --git a/src/scene/shader-lib/chunks-wgsl/gsplat/vert/gsplatCommon.js b/src/scene/shader-lib/chunks-wgsl/gsplat/vert/gsplatCommon.js
index d58094957..5fedb04e2 100644
--- a/src/scene/shader-lib/chunks-wgsl/gsplat/vert/gsplatCommon.js
+++ b/src/scene/shader-lib/chunks-wgsl/gsplat/vert/gsplatCommon.js
@@ -25,6 +25,20 @@ struct SplatCorner {
     #endif
 }
 
+fn quatToMat3(R: vec4<f32>) -> mat3x3<f32> {
+    let R2: vec4<f32> = R + R;
+    let X: f32       = R2.x * R.w;
+    let Y: vec4<f32> = R2.y * R;
+    let Z: vec4<f32> = R2.z * R;
+    let W: f32       = R2.w * R.w;
+
+    return mat3x3<f32>(
+        1.0 - Z.z - W,  Y.z + X,      Y.w - Z.x,
+        Y.z - X,        1.0 - Y.y - W, Z.w + Y.x,
+        Y.w + Z.x,      Z.w - Y.x,     1.0 - Y.y - Z.z
+    );
+}
+
 #if GSPLAT_COMPRESSED_DATA == true
     #include "gsplatCompressedDataVS"
     #include "gsplatCompressedSHVS"
diff --git a/src/scene/shader-lib/chunks-wgsl/gsplat/vert/gsplatCompressedData.js b/src/scene/shader-lib/chunks-wgsl/gsplat/vert/gsplatCompressedData.js
index 487f4d6bd..c91cafa10 100644
--- a/src/scene/shader-lib/chunks-wgsl/gsplat/vert/gsplatCompressedData.js
+++ b/src/scene/shader-lib/chunks-wgsl/gsplat/vert/gsplatCompressedData.js
@@ -38,27 +38,6 @@ fn unpackRotation(bits: u32) -> vec4f {
     return vec4f(a, b, c, m);
 }
 
-fn quatToMat3(R: vec4f) -> mat3x3f {
-    let qw_scalar = R.x;
-    let qx_vec = R.y;
-    let qy_vec = R.z;
-    let qz_vec = R.w;
-
-    return mat3x3f(
-        vec3f(1.0 - 2.0 * (qy_vec * qy_vec + qz_vec * qz_vec),
-              2.0 * (qx_vec * qy_vec + qw_scalar * qz_vec),
-              2.0 * (qx_vec * qz_vec - qw_scalar * qy_vec)),
-
-        vec3f(2.0 * (qx_vec * qy_vec - qw_scalar * qz_vec),
-              1.0 - 2.0 * (qx_vec * qx_vec + qz_vec * qz_vec),
-              2.0 * (qy_vec * qz_vec + qw_scalar * qx_vec)),
-
-        vec3f(2.0 * (qx_vec * qz_vec + qw_scalar * qy_vec),
-              2.0 * (qy_vec * qz_vec - qw_scalar * qx_vec),
-              1.0 - 2.0 * (qx_vec * qx_vec + qy_vec * qy_vec))
-    );
-}
-
 // read center
 fn readCenter(source: ptr<function, SplatSource>) -> vec3f {
     let tex_size_u = textureDimensions(chunkTexture, 0);
diff --git a/src/scene/shader-lib/chunks-wgsl/gsplat/vert/gsplatData.js b/src/scene/shader-lib/chunks-wgsl/gsplat/vert/gsplatData.js
index d95fe9f14..d6e08683e 100644
--- a/src/scene/shader-lib/chunks-wgsl/gsplat/vert/gsplatData.js
+++ b/src/scene/shader-lib/chunks-wgsl/gsplat/vert/gsplatData.js
@@ -13,34 +13,6 @@ fn readCenter(source: ptr<function, SplatSource>) -> vec3f {
     return bitcast<vec3f>(tA.xyz);
 }
 
-fn quatToMat3(R: vec4f) -> mat3x3f {
-    let x: f32 = R.w;
-    let y: f32 = R.x;
-    let z: f32 = R.y;
-    let w: f32 = R.z;
-
-    // pre-calculate squares and products
-    let xx: f32 = x * x; let yy: f32 = y * y; let zz: f32 = z * z; let ww: f32 = w * w;
-    let xy: f32 = x * y; let xz: f32 = x * z; let xw: f32 = x * w;
-    let yz: f32 = y * z; let yw: f32 = y * w; let zw: f32 = z * w;
-
-    return mat3x3f(
-        vec3f(
-            1.0 - 2.0 * (zz + ww),
-            2.0 * (yz + xw),
-            2.0 * (yw - xz)
-        ), vec3f(
-            2.0 * (yz - xw),
-            1.0 - 2.0 * (yy + ww),
-            2.0 * (zw + xy)
-        ), vec3f(
-            2.0 * (yw + xz),
-            2.0 * (zw - xy),
-            1.0 - 2.0 * (yy + zz)
-        )
-    );
-}
-
 fn unpackRotation(packed: vec3f) -> vec4f {
     return vec4f(packed.xyz, sqrt(max(0.0, 1.0 - dot(packed, packed))));
 }
@@ -49,7 +21,7 @@ fn unpackRotation(packed: vec3f) -> vec4f {
 fn readCovariance(source: ptr<function, SplatSource>, covA_ptr: ptr<function, vec3f>, covB_ptr: ptr<function, vec3f>) {
     let tB: vec4f = textureLoad(transformB, source.uv, 0);
 
-    let rot: mat3x3f = quatToMat3(unpackRotation(vec3f(unpack2x16float(bitcast<u32>(tAw)), tB.w)));
+    let rot: mat3x3f = quatToMat3(unpackRotation(vec3f(unpack2x16float(bitcast<u32>(tAw)), tB.w)).wxyz);
     let scale: vec3f = tB.xyz;
 
     // M = S * R
diff --git a/src/scene/shader-lib/chunks-wgsl/gsplat/vert/gsplatSH.js b/src/scene/shader-lib/chunks-wgsl/gsplat/vert/gsplatSH.js
index 36a295b82..98272602b 100644
--- a/src/scene/shader-lib/chunks-wgsl/gsplat/vert/gsplatSH.js
+++ b/src/scene/shader-lib/chunks-wgsl/gsplat/vert/gsplatSH.js
@@ -7,31 +7,53 @@ fn unpack111011s(bits: u32) -> vec3f {
     return (vec3f((vec3<u32>(bits) >> vec3<u32>(21u, 11u, 0u)) & vec3<u32>(0x7ffu, 0x3ffu, 0x7ffu)) / vec3f(2047.0, 1023.0, 2047.0)) * 2.0 - 1.0;
 }
 
+struct ScaleAndSH {
+    scale: f32,
+    a: vec3f,
+    b: vec3f,
+    c: vec3f
+};
+
 // fetch quantized spherical harmonic coefficients
-fn fetchScale(t_in: vec4<u32>, scale: ptr<function, f32>, a: ptr<function, vec3f>, b: ptr<function, vec3f>, c: ptr<function, vec3f>) {
-    *scale = bitcast<f32>(t_in.x);
-    *a = unpack111011s(t_in.y);
-    *b = unpack111011s(t_in.z);
-    *c = unpack111011s(t_in.w);
+fn fetchScale(t_in: vec4<u32>) -> ScaleAndSH {
+    var result: ScaleAndSH;
+    result.scale = bitcast<f32>(t_in.x);
+    result.a = unpack111011s(t_in.y);
+    result.b = unpack111011s(t_in.z);
+    result.c = unpack111011s(t_in.w);
+    return result;
 }
 
+struct SH {
+    a: vec3f,
+    b: vec3f,
+    c: vec3f,
+    d: vec3f
+};
+
 // fetch quantized spherical harmonic coefficients
-fn fetch4(t_in: vec4<u32>, a: ptr<function, vec3f>, b: ptr<function, vec3f>, c: ptr<function, vec3f>, d: ptr<function, vec3f>) {
-    *a = unpack111011s(t_in.x);
-    *b = unpack111011s(t_in.y);
-    *c = unpack111011s(t_in.z);
-    *d = unpack111011s(t_in.w);
+fn fetch4(t_in: vec4<u32>) -> SH {
+    var result: SH;
+    result.a = unpack111011s(t_in.x);
+    result.b = unpack111011s(t_in.y);
+    result.c = unpack111011s(t_in.z);
+    result.d = unpack111011s(t_in.w);
+    return result;
 }
 
-fn fetch1(t_in: u32, a: ptr<function, vec3f>) {
-    *a = unpack111011s(t_in);
+fn fetch1(t_in: u32) -> vec3f {
+    return unpack111011s(t_in);
 }
 
 #if SH_BANDS == 1
     var splatSH_1to3: texture_2d<u32>;
 
     fn readSHData(source: ptr<function, SplatSource>, sh: ptr<function, array<vec3f, 3>>, scale: ptr<function, f32>) {
-        fetchScale(textureLoad(splatSH_1to3, source.uv, 0), scale, &sh[0], &sh[1], &sh[2]);
+        let result = fetchScale(textureLoad(splatSH_1to3, source.uv, 0));
+        *scale = result.scale;
+        sh[0] = result.a;
+        sh[1] = result.b;
+        sh[2] = result.c;
     }
 #elif SH_BANDS == 2
     var splatSH_1to3: texture_2d<u32>;
@@ -39,9 +61,19 @@ fn fetch1(t_in: u32, a: ptr<function, vec3f>) {
     var splatSH_8to11: texture_2d<u32>;
 
     fn readSHData(source: ptr<function, SplatSource>, sh: ptr<function, array<vec3f, 8>>, scale: ptr<function, f32>) {
-        fetchScale(textureLoad(splatSH_1to3, source.uv, 0), scale, &sh[0], &sh[1], &sh[2]);
-        fetch4(textureLoad(splatSH_4to7, source.uv, 0), &sh[3], &sh[4], &sh[5], &sh[6]);
-        fetch1(textureLoad(splatSH_8to11, source.uv, 0).x, &sh[7]);
+        let first: ScaleAndSH = fetchScale(textureLoad(splatSH_1to3, source.uv, 0));
+        *scale = first.scale;
+        sh[0] = first.a;
+        sh[1] = first.b;
+        sh[2] = first.c;
+
+        let second: SH = fetch4(textureLoad(splatSH_4to7, source.uv, 0));
+        sh[3] = second.a;
+        sh[4] = second.b;
+        sh[5] = second.c;
+        sh[6] = second.d;
+
+        sh[7] = fetch1(textureLoad(splatSH_8to11, source.uv, 0).x);
     }
 #else
     var splatSH_1to3: texture_2d<u32>;
@@ -50,10 +82,29 @@ fn fetch1(t_in: u32, a: ptr<function, vec3f>) {
     var splatSH_12to15: texture_2d<u32>;
 
     fn readSHData(source: ptr<function, SplatSource>, sh: ptr<function, array<vec3f, 15>>, scale: ptr<function, f32>) {
-        fetchScale(textureLoad(splatSH_1to3, source.uv, 0), scale, &sh[0], &sh[1], &sh[2]);
-        fetch4(textureLoad(splatSH_4to7, source.uv, 0), &sh[3], &sh[4], &sh[5], &sh[6]);
-        fetch4(textureLoad(splatSH_8to11, source.uv, 0), &sh[7], &sh[8], &sh[9], &sh[10]);
-        fetch4(textureLoad(splatSH_12to15, source.uv, 0), &sh[11], &sh[12], &sh[13], &sh[14]);
+        let first: ScaleAndSH = fetchScale(textureLoad(splatSH_1to3, source.uv, 0));
+        *scale = first.scale;
+        sh[0] = first.a;
+        sh[1] = first.b;
+        sh[2] = first.c;
+
+        let second: SH = fetch4(textureLoad(splatSH_4to7, source.uv, 0));
+        sh[3] = second.a;
+        sh[4] = second.b;
+        sh[5] = second.c;
+        sh[6] = second.d;
+
+        let third: SH = fetch4(textureLoad(splatSH_8to11, source.uv, 0));
+        sh[7] = third.a;
+        sh[8] = third.b;
+        sh[9] = third.c;
+        sh[10] = third.d;
+
+        let fourth: SH = fetch4(textureLoad(splatSH_12to15, source.uv, 0));
+        sh[11] = fourth.a;
+        sh[12] = fourth.b;
+        sh[13] = fourth.c;
+        sh[14] = fourth.d;
     }
 #endif
 


Test patch:
diff --git a/test/framework/components/element/text-element.test.mjs b/test/framework/components/element/text-element.test.mjs
index 09b35f4e3..5abacd837 100644
--- a/test/framework/components/element/text-element.test.mjs
+++ b/test/framework/components/element/text-element.test.mjs
@@ -1574,13 +1574,13 @@ describe('TextElement', function () {
     });
 
     it('text element removes i18n event listeners on destroy', function () {
-        expect(app.i18n.hasEvent('set:locale')).to.equal(true);
+        expect(app.i18n.hasEvent('change')).to.equal(true);
         expect(app.i18n.hasEvent('data:add')).to.equal(true);
         expect(app.i18n.hasEvent('data:remove')).to.equal(true);
 
         element.entity.destroy();
 
-        expect(app.i18n.hasEvent('set:locale')).to.equal(false);
+        expect(app.i18n.hasEvent('change')).to.equal(false);
         expect(app.i18n.hasEvent('data:add')).to.equal(false);
         expect(app.i18n.hasEvent('data:remove')).to.equal(false);
     });