Repository: playcanvas/engine
Pull request #6715 (2024-06-18 14:28:02)

GSplat speedup
In this PR we:
* speed up the load-time reordering of uncompressed splat data by roughly 1/3rd for large scenes
    * time to reorder the 6 million splat bicycle scene goes from 3.2s to 2.3s on Chrome Macbook M1
* simplify code slightly by moving some of the load-time logic from `GSplatData` to `PLYParser`
* small render speedup by removing the `id` varying in uncompressed shader when it's not needed


Patch:
diff --git a/src/framework/components/script/component.js b/src/framework/components/script/component.js
index f36c02d3a..b99fd3efc 100644
--- a/src/framework/components/script/component.js
+++ b/src/framework/components/script/component.js
@@ -1,7 +1,7 @@
 import { Debug } from '../../../core/debug.js';
 import { SortedLoopArray } from '../../../core/sorted-loop-array.js';
 
-import { ScriptAttributes } from '../../script/script-attributes.js';
+import { ScriptAttributes, assignAttributesToScript } from '../../script/script-attributes.js';
 import {
     SCRIPT_INITIALIZE, SCRIPT_POST_INITIALIZE, SCRIPT_UPDATE,
     SCRIPT_POST_UPDATE, SCRIPT_SWAP
@@ -9,6 +9,10 @@ import {
 
 import { Component } from '../component.js';
 import { Entity } from '../../entity.js';
+import { ScriptType } from '../../script/script-type.js';
+import { getScriptName } from '../../script/script.js';
+
+const toLowerCamelCase = str => str[0].toLowerCase() + str.substring(1);
 
 /**
  * The ScriptComponent allows you to extend the functionality of an Entity by attaching your own
@@ -18,6 +22,14 @@ import { Entity } from '../../entity.js';
  * @category Script
  */
 class ScriptComponent extends Component {
+    /**
+     * A map of script name to initial component data.
+     *
+     * @type {Map<string, object>}
+     * @private
+     */
+    _attributeDataMap = new Map();
+
     /**
      * Fired when a {@link ScriptType} instance is created and attached to the script component.
      * This event is available in two forms. They are as follows:
@@ -191,7 +203,7 @@ class ScriptComponent extends Component {
      * Sets the array of all script instances attached to an entity. This array is read-only and
      * should not be modified by developer.
      *
-     * @type {import('../../script/script-type.js').ScriptType[]}
+     * @type {import('../../script/script.js').Script[]}
      */
     set scripts(value) {
         this._scriptsData = value;
@@ -205,8 +217,14 @@ class ScriptComponent extends Component {
                 // existing script
 
                 // enabled
-                if (typeof value[key].enabled === 'boolean')
+                if (typeof value[key].enabled === 'boolean') {
+
+                    // Before a script is initialized, initialize any attributes
+                    script.once('preInitialize', () => {
+                        this.initializeAttributes(script);
+                    });
                     script.enabled = !!value[key].enabled;
+                }
 
                 // attributes
                 if (typeof value[key].attributes === 'object') {
@@ -330,6 +348,9 @@ class ScriptComponent extends Component {
 
         for (let i = 0, len = this.scripts.length; i < len; i++) {
             const script = this.scripts[i];
+            script.once('preInitialize', () => {
+                this.initializeAttributes(script);
+            });
             script.enabled = script._enabled;
         }
 
@@ -368,8 +389,40 @@ class ScriptComponent extends Component {
     }
 
     _onInitializeAttributes() {
-        for (let i = 0, len = this.scripts.length; i < len; i++)
-            this.scripts[i].__initializeAttributes();
+        for (let i = 0, len = this.scripts.length; i < len; i++) {
+            const script = this.scripts[i];
+            this.initializeAttributes(script);
+        }
+    }
+
+    initializeAttributes(script) {
+
+        // if script has __initializeAttributes method assume it has a runtime schema
+        if (script instanceof ScriptType) {
+
+            script.__initializeAttributes();
+
+        } else {
+
+            // otherwise we need to manually initialize attributes from the schema
+            const name = script.__scriptType.__name;
+            const data = this._attributeDataMap.get(name);
+
+            // If not data exists return early
+            if (!data) {
+                return;
+            }
+
+            // Fetch schema and warn if it doesn't exist
+            const schema = this.system.app.scripts?.getSchema(name);
+            if (!schema) {
+                Debug.warnOnce(`No schema exists for the script '${name}'. A schema must exist for data to be instantiated on the script.`);
+            }
+
+            // Assign the attributes to the script instance based on the attribute schema
+            assignAttributesToScript(this.system.app, schema.attributes, data, script);
+
+        }
     }
 
     _scriptMethod(script, method, arg) {
@@ -599,8 +652,8 @@ class ScriptComponent extends Component {
     /**
      * Create a script instance and attach to an entity script component.
      *
-     * @param {string|typeof import('../../script/script-type.js').ScriptType} nameOrType - The
-     * name or type of {@link ScriptType}.
+     * @param {string|typeof import('../../script/script.js').Script} nameOrType - The
+     * name or type of {@link Script}.
      * @param {object} [args] - Object with arguments for a script.
      * @param {boolean} [args.enabled] - If script instance is enabled after creation. Defaults to
      * true.
@@ -631,7 +684,7 @@ class ScriptComponent extends Component {
         if (typeof scriptType === 'string') {
             scriptType = this.system.app.scripts.get(scriptType);
         } else if (scriptType) {
-            scriptName = scriptType.__name;
+            scriptName = scriptType.__name ?? toLowerCamelCase(getScriptName(scriptType));
         }
 
         if (scriptType) {
@@ -644,6 +697,15 @@ class ScriptComponent extends Component {
                     attributes: args.attributes
                 });
 
+
+                // If the script is not a ScriptType then we must store attribute data on the component
+                if (!(scriptInstance instanceof ScriptType)) {
+
+                    // Store the Attribute data
+                    this._attributeDataMap.set(scriptName, args.attributes);
+
+                }
+
                 const len = this._scripts.length;
                 let ind = -1;
                 if (typeof args.ind === 'number' && args.ind !== -1 && len > args.ind)
@@ -661,7 +723,7 @@ class ScriptComponent extends Component {
                 this[scriptName] = scriptInstance;
 
                 if (!args.preloading)
-                    scriptInstance.__initializeAttributes();
+                    this.initializeAttributes(scriptInstance);
 
                 this.fire('create', scriptName, scriptInstance);
                 this.fire('create:' + scriptName, scriptInstance);
@@ -725,6 +787,8 @@ class ScriptComponent extends Component {
         delete this._scriptsIndex[scriptName];
         if (!scriptData) return false;
 
+        this._attributeDataMap.delete(scriptName);
+
         const scriptInstance = scriptData.instance;
         if (scriptInstance && !scriptInstance._destroyed) {
             scriptInstance.enabled = false;
@@ -793,7 +857,7 @@ class ScriptComponent extends Component {
         if (!scriptInstance.swap)
             return false;
 
-        scriptInstance.__initializeAttributes();
+        this.initializeAttributes(scriptInstance);
 
         // add to component
         this._scripts[ind] = scriptInstance;
diff --git a/src/framework/handlers/script.js b/src/framework/handlers/script.js
index bca774bf9..5940350b9 100644
--- a/src/framework/handlers/script.js
+++ b/src/framework/handlers/script.js
@@ -1,12 +1,11 @@
 import { platform } from '../../core/platform.js';
 import { script } from '../script.js';
-import { ScriptType } from '../script/script-type.js';
 import { ScriptTypes } from '../script/script-types.js';
-import { registerScript } from '../script/script.js';
+import { registerScript } from '../script/script-create.js';
 import { ResourceLoader } from './loader.js';
 
 import { ResourceHandler } from './handler.js';
-import { ScriptAttributes } from '../script/script-attributes.js';
+import { Script } from '../script/script.js';
 
 const toLowerCamelCase = str => str[0].toLowerCase() + str.substring(1);
 
@@ -134,21 +133,22 @@ class ScriptHandler extends ResourceHandler {
         // @ts-ignore
         import(importUrl.toString()).then((module) => {
 
+            const filename = importUrl.pathname.split('/').pop();
+            const scriptSchema = this._app.assets.find(filename, 'script').data.scripts;
+
             for (const key in module) {
                 const scriptClass = module[key];
-                const extendsScriptType = scriptClass.prototype instanceof ScriptType;
+                const extendsScriptType = scriptClass.prototype instanceof Script;
 
                 if (extendsScriptType) {
 
-                    // Check if attributes is defined directly on the class and not inherited
-                    if (scriptClass.hasOwnProperty('attributes')) {
-                        const attributes = new ScriptAttributes(scriptClass);
-                        for (const key in scriptClass.attributes) {
-                            attributes.add(key, scriptClass.attributes[key]);
-                        }
-                        scriptClass.attributes = attributes;
-                    }
-                    registerScript(scriptClass, toLowerCamelCase(scriptClass.name));
+                    const scriptName = toLowerCamelCase(scriptClass.name);
+
+                    // Register the script name
+                    registerScript(scriptClass, scriptName);
+
+                    // Store any schema associated with the script
+                    this._app.scripts.addSchema(scriptName, scriptSchema[scriptName]);
                 }
             }
 
diff --git a/src/framework/parsers/ply.js b/src/framework/parsers/ply.js
index 18822f212..796b81efd 100644
--- a/src/framework/parsers/ply.js
+++ b/src/framework/parsers/ply.js
@@ -1,5 +1,6 @@
 import { GSplatData } from '../../scene/gsplat/gsplat-data.js';
 import { GSplatResource } from './gsplat-resource.js';
+import { Mat4 } from '../../core/math/mat4.js';
 
 const magicBytes = new Uint8Array([112, 108, 121, 10]);                                                 // ply\n
 const endHeaderBytes = new Uint8Array([10, 101, 110, 100, 95, 104, 101, 97, 100, 101, 114, 10]);        // \nend_header\n
@@ -258,6 +259,8 @@ const defaultElements = [
 const defaultElementsSet = new Set(defaultElements);
 const defaultElementFilter = val => defaultElementsSet.has(val);
 
+const mat4 = new Mat4();
+
 class PlyParser {
     /** @type {import('../../platform/graphics/graphics-device.js').GraphicsDevice} */
     device;
@@ -295,10 +298,21 @@ class PlyParser {
             readPly(response.body.getReader(), asset.data.elementFilter ?? defaultElementFilter)
                 .then((response) => {
                     // construct the GSplatData object
-                    const gsplatData = new GSplatData(response, {
-                        performZScale: asset.data.performZScale,
-                        reorder: asset.data.reorder
-                    });
+                    const gsplatData = new GSplatData(response);
+
+                    if (!gsplatData.isCompressed) {
+
+                        // perform Z scale
+                        if (asset.data.performZScale ?? true) {
+                            mat4.setScale(-1, -1, 1);
+                            gsplatData.transform(mat4);
+                        }
+
+                        // reorder data
+                        if (asset.data.reorder ?? true) {
+                            gsplatData.reorderData();
+                        }
+                    }
 
                     // construct the resource
                     const resource = new GSplatResource(
diff --git a/src/framework/script/script-attributes.js b/src/framework/script/script-attributes.js
index 0d0dc1d90..154868a94 100644
--- a/src/framework/script/script-attributes.js
+++ b/src/framework/script/script-attributes.js
@@ -5,9 +5,7 @@ import { CurveSet } from '../../core/math/curve-set.js';
 import { Vec2 } from '../../core/math/vec2.js';
 import { Vec3 } from '../../core/math/vec3.js';
 import { Vec4 } from '../../core/math/vec4.js';
-
 import { GraphNode } from '../../scene/graph-node.js';
-
 import { Asset } from '../asset/asset.js';
 
 const components = ['x', 'y', 'z', 'w'];
@@ -148,6 +146,50 @@ function rawToValue(app, args, value, old) {
     return value;
 }
 
+/**
+ * @typedef {Object} AttributeSchema
+ * @property {"boolean"|"number"|"string"|"json"|"asset"|"entity"|"rgb"|"rgba"|"vec2"|"vec3"|"vec4"|"curve"} type - The Attribute type
+ * @property {boolean} [array] - True if this attribute is an array of `type`
+ */
+
+/**
+ * Takes an attribute schema, a value and current value, and return a new value.
+ *
+ * @param {import('../../framework/application.js').Application} app - The working application
+ * @param {AttributeSchema} schema - The attribute schema used to resolve properties
+ * @param {*} value - The raw value to create
+ * @param {*} current - The existing value
+ * @returns {*} The return value
+ */
+function attributeToValue(app, schema, value, current) {
+    if (schema.array) {
+        return value.map((item, index) => rawToValue(app, schema, item, current ? current[index] : null));
+    }
+
+    return rawToValue(app, schema, value, current);
+}
+
+/**
+ * Assigns values to a script instance based on a map of attributes schemas
+ * and a corresponding map of data.
+ *
+ * @param {import('../../framework/application.js').Application} app - The application instance
+ * @param {Object<string, AttributeSchema>} attributeSchemaMap - A map of names to Schemas
+ * @param {Object<string, *>} data - A Map of data to assign to the Script instance
+ * @param {import('../../framework/script/script.js').Script} script - A Script instance to assign values on
+ */
+export function assignAttributesToScript(app, attributeSchemaMap, data, script) {
+
+    // Iterate over the schema and assign corresponding data
+    for (const attributeName in attributeSchemaMap) {
+        const attributeSchema = attributeSchemaMap[attributeName];
+        const dataToAssign = data[attributeName];
+
+        // Assign the value to the script based on the attribute schema
+        script[attributeName] =  attributeToValue(app, attributeSchema, dataToAssign, script);
+    }
+}
+
 /**
  * Container of Script Attribute definitions. Implements an interface to add/remove attributes and
  * store their definition for a {@link ScriptType}. Note: An instance of ScriptAttributes is
@@ -156,6 +198,8 @@ function rawToValue(app, args, value, old) {
  * @category Script
  */
 class ScriptAttributes {
+    static assignAttributesToScript = assignAttributesToScript;
+
     /**
      * Create a new ScriptAttributes instance.
      *
diff --git a/src/framework/script/script-create.js b/src/framework/script/script-create.js
new file mode 100644
index 000000000..2a1c04fbf
--- /dev/null
+++ b/src/framework/script/script-create.js
@@ -0,0 +1,143 @@
+import { EventHandler } from '../../core/event-handler.js';
+import { AppBase } from '../app-base.js';
+import { ScriptAttributes } from './script-attributes.js';
+import { ScriptType } from './script-type.js';
+import { ScriptTypes } from './script-types.js';
+import { Script } from './script.js';
+
+const reservedScriptNames = new Set([
+    'system', 'entity', 'create', 'destroy', 'swap', 'move', 'data',
+    'scripts', '_scripts', '_scriptsIndex', '_scriptsData',
+    'enabled', '_oldState', 'onEnable', 'onDisable', 'onPostStateChange',
+    '_onSetEnabled', '_checkState', '_onBeforeRemove',
+    '_onInitializeAttributes', '_onInitialize', '_onPostInitialize',
+    '_onUpdate', '_onPostUpdate',
+    '_callbacks', '_callbackActive', 'has', 'get', 'on', 'off', 'fire', 'once', 'hasEvent'
+]);
+
+function getReservedScriptNames() {
+    return reservedScriptNames;
+}
+
+/**
+ * Create and register a new {@link ScriptType}. It returns new class type (constructor function),
+ * which is auto-registered to {@link ScriptRegistry} using its name. This is the main interface to
+ * create Script Types, to define custom logic using JavaScript, that is used to create interaction
+ * for entities.
+ *
+ * @param {string} name - Unique Name of a Script Type. If a Script Type with the same name has
+ * already been registered and the new one has a `swap` method defined in its prototype, then it
+ * will perform hot swapping of existing Script Instances on entities using this new Script Type.
+ * Note: There is a reserved list of names that cannot be used, such as list below as well as some
+ * starting from `_` (underscore): system, entity, create, destroy, swap, move, scripts, onEnable,
+ * onDisable, onPostStateChange, has, on, off, fire, once, hasEvent.
+ * @param {AppBase} [app] - Optional application handler, to choose which {@link ScriptRegistry}
+ * to add a script to. By default it will use `Application.getApplication()` to get current
+ * {@link AppBase}.
+ * @returns {typeof ScriptType|null} A class type (constructor function) that inherits {@link ScriptType},
+ * which the developer is meant to further extend by adding attributes and prototype methods.
+ * Returns null if there was an error.
+ * @example
+ * var Turning = pc.createScript('turn');
+ *
+ * // define 'speed' attribute that is available in Editor UI
+ * Turning.attributes.add('speed', {
+ *     type: 'number',
+ *     default: 180,
+ *     placeholder: 'deg/s'
+ * });
+ *
+ * // runs every tick
+ * Turning.prototype.update = function (dt) {
+ *     this.entity.rotate(0, this.speed * dt, 0);
+ * };
+ * @category Script
+ */
+function createScript(name, app) {
+    if (reservedScriptNames.has(name))
+        throw new Error(`Script name '${name}' is reserved, please rename the script`);
+
+    const scriptType = function (args) {
+        EventHandler.prototype.initEventHandler.call(this);
+        ScriptType.prototype.initScriptType.call(this, args);
+    };
+
+    scriptType.prototype = Object.create(ScriptType.prototype);
+    scriptType.prototype.constructor = scriptType;
+
+    scriptType.extend = ScriptType.extend;
+    scriptType.attributes = new ScriptAttributes(scriptType);
+
+    registerScript(scriptType, name, app);
+    return scriptType;
+}
+
+// Editor uses this - migrate to ScriptAttributes.reservedNames and delete this
+const reservedAttributes = {};
+ScriptAttributes.reservedNames.forEach((value, value2, set) => {
+    reservedAttributes[value] = 1;
+});
+createScript.reservedAttributes = reservedAttributes;
+
+/* eslint-disable jsdoc/check-examples */
+/**
+ * Register a existing class type as a Script Type to {@link ScriptRegistry}. Useful when defining
+ * a ES6 script class that extends {@link ScriptType} (see example).
+ *
+ * @param {typeof ScriptType} script - The existing class type (constructor function) to be
+ * registered as a Script Type. Class must extend {@link ScriptType} (see example). Please note: A
+ * class created using {@link createScript} is auto-registered, and should therefore not be pass
+ * into {@link registerScript} (which would result in swapping out all related script instances).
+ * @param {string} [name] - Optional unique name of the Script Type. By default it will use the
+ * same name as the existing class. If a Script Type with the same name has already been registered
+ * and the new one has a `swap` method defined in its prototype, then it will perform hot swapping
+ * of existing Script Instances on entities using this new Script Type. Note: There is a reserved
+ * list of names that cannot be used, such as list below as well as some starting from `_`
+ * (underscore): system, entity, create, destroy, swap, move, scripts, onEnable, onDisable,
+ * onPostStateChange, has, on, off, fire, once, hasEvent.
+ * @param {AppBase} [app] - Optional application handler, to choose which {@link ScriptRegistry}
+ * to register the script type to. By default it will use `Application.getApplication()` to get
+ * current {@link AppBase}.
+ * @example
+ * // define a ES6 script class
+ * class PlayerController extends pc.ScriptType {
+ *
+ *     initialize() {
+ *         // called once on initialize
+ *     }
+ *
+ *     update(dt) {
+ *         // called each tick
+ *     }
+ * }
+ *
+ * // register the class as a script
+ * pc.registerScript(PlayerController);
+ *
+ * // declare script attributes (Must be after pc.registerScript())
+ * PlayerController.attributes.add('attribute1', {type: 'number'});
+ * @category Script
+ */
+function registerScript(script, name, app) {
+    if (typeof script !== 'function')
+        throw new Error(`script class: '${script}' must be a constructor function (i.e. class).`);
+
+    if (!(script.prototype instanceof Script))
+        throw new Error(`script class: '${ScriptType.__getScriptName(script)}' does not extend pc.Script.`);
+
+    name = name || script.__name || ScriptType.__getScriptName(script);
+
+    if (reservedScriptNames.has(name))
+        throw new Error(`script name: '${name}' is reserved, please change script name`);
+
+    script.__name = name;
+
+    // add to scripts registry
+    const registry = app ? app.scripts : AppBase.getApplication().scripts;
+    registry.add(script);
+
+    ScriptTypes.push(script);
+}
+/* eslint-enable jsdoc/check-examples */
+
+export { createScript, registerScript, getReservedScriptNames };
diff --git a/src/framework/script/script-registry.js b/src/framework/script/script-registry.js
index 12f08201c..752151f2f 100644
--- a/src/framework/script/script-registry.js
+++ b/src/framework/script/script-registry.js
@@ -20,6 +20,14 @@ class ScriptRegistry extends EventHandler {
      */
     _list = [];
 
+    /**
+     * A Map of script names to attribute schemas.
+     *
+     * @type {Map<string, import('./script-attributes.js').AttributeSchema>}
+     * @private
+     */
+    _scriptSchemas = new Map();
+
     /**
      * Create a new ScriptRegistry instance.
      *
@@ -36,6 +44,27 @@ class ScriptRegistry extends EventHandler {
         this.off();
     }
 
+    /**
+     * Registers a schema against a script instance.
+     *
+     * @param {string} id - The key to use to store the schema
+     * @param {import('./script-attributes.js').AttributeSchema} schema - An schema definition for the script
+     */
+    addSchema(id, schema) {
+        if (!schema) return;
+        this._scriptSchemas.set(id, schema);
+    }
+
+    /**
+     * Returns a schema for a given script name.
+     *
+     * @param {string} id - The key to store the schema under
+     * @returns {import('./script-attributes.js').AttributeSchema | undefined} - The schema stored under the key
+     */
+    getSchema(id) {
+        return this._scriptSchemas.get(id);
+    }
+
     /**
      * Add {@link ScriptType} to registry. Note: when {@link createScript} is called, it will add
      * the {@link ScriptType} to the registry automatically. If a script already exists in
@@ -110,13 +139,14 @@ class ScriptRegistry extends EventHandler {
 
                     if (scriptInstance)
                         scriptInstances.push(scriptInstance);
+
+                    // initialize attributes
+                    for (const script of component.scripts) {
+                        component.initializeAttributes(script);
+                    }
                 }
             }
 
-            // initialize attributes
-            for (let i = 0; i < scriptInstances.length; i++)
-                scriptInstances[i].__initializeAttributes();
-
             // call initialize()
             for (let i = 0; i < scriptInstances.length; i++) {
                 if (scriptInstances[i].enabled) {
diff --git a/src/framework/script/script-type.js b/src/framework/script/script-type.js
index 97c49855b..19b93f924 100644
--- a/src/framework/script/script-type.js
+++ b/src/framework/script/script-type.js
@@ -1,296 +1,20 @@
-import { Debug } from '../../core/debug.js';
-import { EventHandler } from '../../core/event-handler.js';
-
-import { SCRIPT_INITIALIZE, SCRIPT_POST_INITIALIZE } from './constants.js';
 import { ScriptAttributes } from './script-attributes.js';
-
-const funcNameRegex = new RegExp('^\\s*function(?:\\s|\\s*\\/\\*.*\\*\\/\\s*)+([^\\(\\s\\/]*)\\s*');
+import { Script } from './script.js';
 
 /**
- * Represents the type of a script. It is returned by {@link createScript}. Also referred to as
- * Script Type.
- *
- * The type is to be extended using its JavaScript prototype. There is a list of methods that will
- * be executed by the engine on instances of this type, such as:
- *
- * - `initialize`
- * - `postInitialize`
- * - `update`
- * - `postUpdate`
- * - `swap`
- *
- * `initialize` and `postInitialize` - are called (if defined) when a script is about to run for
- * the first time - `postInitialize` will run after all `initialize` methods are executed in the
- * same tick or enabling chain of actions.
- *
- * `update` and `postUpdate` - are called (if defined) for enabled (running state) scripts on each
- * tick.
- *
- * `swap` - is called when a ScriptType that already exists in the registry gets redefined. If the
- * new ScriptType has a `swap` method in its prototype, then it will be executed to perform hot-
- * reload at runtime.
+ * This is the legacy format for creating PlayCanvas script returned when calling `pc.createScript()`.
+ * You should not use this inherit from this class directly.
  *
+ * @deprecated Use {@link Script} instead.
  * @category Script
  */
-class ScriptType extends EventHandler {
-    /**
-     * Fired when a script instance becomes enabled.
-     *
-     * @event
-     * @example
-     * PlayerController.prototype.initialize = function () {
-     *     this.on('enable', () => {
-     *         // Script Instance is now enabled
-     *     });
-     * };
-     */
-    static EVENT_ENABLE = 'enable';
-
-    /**
-     * Fired when a script instance becomes disabled.
-     *
-     * @event
-     * @example
-     * PlayerController.prototype.initialize = function () {
-     *     this.on('disable', () => {
-     *         // Script Instance is now disabled
-     *     });
-     * };
-     */
-    static EVENT_DISABLE = 'disable';
-
-    /**
-     * Fired when a script instance changes state to enabled or disabled. The handler is passed a
-     * boolean parameter that states whether the script instance is now enabled or disabled.
-     *
-     * @event
-     * @example
-     * PlayerController.prototype.initialize = function () {
-     *     this.on('state', (enabled) => {
-     *         console.log(`Script Instance is now ${enabled ? 'enabled' : 'disabled'}`);
-     *     });
-     * };
-     */
-    static EVENT_STATE = 'state';
-
-    /**
-     * Fired when a script instance is destroyed and removed from component.
-     *
-     * @event
-     * @example
-     * PlayerController.prototype.initialize = function () {
-     *     this.on('destroy', () => {
-     *         // no longer part of the entity
-     *         // this is a good place to clean up allocated resources used by the script
-     *     });
-     * };
-     */
-    static EVENT_DESTROY = 'destroy';
-
-    /**
-     * Fired when script attributes have changed. This event is available in two forms. They are as follows:
-     *
-     * 1. `attr` - Fired for any attribute change. The handler is passed the name of the attribute
-     * that changed, the value of the attribute before the change and the value of the attribute
-     * after the change.
-     * 2. `attr:[name]` - Fired for a specific attribute change. The handler is passed the value of
-     * the attribute before the change and the value of the attribute after the change.
-     *
-     * @event
-     * @example
-     * PlayerController.prototype.initialize = function () {
-     *     this.on('attr', (name, newValue, oldValue) => {
-     *         console.log(`Attribute '${name}' changed from '${oldValue}' to '${newValue}'`);
-     *     });
-     * };
-     * @example
-     * PlayerController.prototype.initialize = function () {
-     *     this.on('attr:speed', (newValue, oldValue) => {
-     *         console.log(`Attribute 'speed' changed from '${oldValue}' to '${newValue}'`);
-     *     });
-     * };
-     */
-    static EVENT_ATTR = 'attr';
-
-    /**
-     * Fired when a script instance had an exception. The script instance will be automatically
-     * disabled. The handler is passed an {@link Error} object containing the details of the
-     * exception and the name of the method that threw the exception.
-     *
-     * @event
-     * @example
-     * PlayerController.prototype.initialize = function () {
-     *     this.on('error', (err, method) => {
-     *         // caught an exception
-     *         console.log(err.stack);
-     *     });
-     * };
-     */
-    static EVENT_ERROR = 'error';
-
-    /**
-     * The {@link AppBase} that the instance of this type belongs to.
-     *
-     * @type {import('../app-base.js').AppBase}
-     */
-    app;
-
-    /**
-     * The {@link Entity} that the instance of this type belongs to.
-     *
-     * @type {import('../entity.js').Entity}
-     */
-    entity;
-
-    /** @private */
-    _enabled;
-
-    /** @private */
-    _enabledOld;
-
-    /** @private */
-    _initialized;
-
-    /** @private */
-    _postInitialized;
-
-    /** @private */
-    __destroyed;
-
+class ScriptType extends Script {
     /** @private */
     __attributes;
 
     /** @private */
     __attributesRaw;
 
-    /** @private */
-    __scriptType;
-
-    /**
-     * The order in the script component that the methods of this script instance will run
-     * relative to other script instances in the component.
-     *
-     * @type {number}
-     * @private
-     */
-    __executionOrder;
-
-    /**
-     * Create a new ScriptType instance.
-     *
-     * @param {object} args - The input arguments object.
-     * @param {import('../app-base.js').AppBase} args.app - The {@link AppBase} that is running the
-     * script.
-     * @param {import('../entity.js').Entity} args.entity - The {@link Entity} that the script is
-     * attached to.
-     */
-    constructor(args) {
-        super();
-        this.initScriptType(args);
-    }
-
-    /**
-     * Sets the enabled state for this ScriptType. False when script is not running, because the
-     * Entity or any of its parents are disabled or the {@link ScriptComponent} is disabled or the
-     * Script Instance is disabled. When disabled no update methods will be called on each tick.
-     * initialize and postInitialize methods will run once when the script instance is in `enabled`
-     * state during app tick.
-     *
-     * @type {boolean}
-     */
-    set enabled(value) {
-        this._enabled = !!value;
-
-        if (this.enabled === this._enabledOld) return;
-
-        this._enabledOld = this.enabled;
-        this.fire(this.enabled ? 'enable' : 'disable');
-        this.fire('state', this.enabled);
-
-        // initialize script if not initialized yet and script is enabled
-        if (!this._initialized && this.enabled) {
-            this._initialized = true;
-
-            this.__initializeAttributes(true);
-
-            if (this.initialize)
-                this.entity.script._scriptMethod(this, SCRIPT_INITIALIZE);
-        }
-
-        // post initialize script if not post initialized yet and still enabled
-        // (initialize might have disabled the script so check this.enabled again)
-        // Warning: Do not do this if the script component is currently being enabled
-        // because in this case post initialize must be called after all the scripts
-        // in the script component have been initialized first
-        if (this._initialized && !this._postInitialized && this.enabled && !this.entity.script._beingEnabled) {
-            this._postInitialized = true;
-
-            if (this.postInitialize)
-                this.entity.script._scriptMethod(this, SCRIPT_POST_INITIALIZE);
-        }
-    }
-
-    /**
-     * Gets the enabled state for this ScriptType.
-     *
-     * @type {boolean}
-     */
-    get enabled() {
-        return this._enabled && !this._destroyed && this.entity.script.enabled && this.entity.enabled;
-    }
-
-    /**
-     * @param {{entity: import('../entity.js').Entity, app: import('../app-base.js').AppBase}} args -
-     * The entity and app.
-     * @private
-     */
-    initScriptType(args) {
-        const script = this.constructor; // get script type, i.e. function (class)
-        Debug.assert(args && args.app && args.entity, `script [${script.__name}] has missing arguments in constructor`);
-
-        this.app = args.app;
-        this.entity = args.entity;
-
-        this._enabled = typeof args.enabled === 'boolean' ? args.enabled : true;
-        this._enabledOld = this.enabled;
-
-        this.__destroyed = false;
-        this.__attributes = { };
-        this.__attributesRaw = args.attributes || { }; // need at least an empty object to make sure default attributes are initialized
-        this.__scriptType = script;
-        this.__executionOrder = -1;
-    }
-
-    /**
-     * Name of a Script Type.
-     *
-     * @type {string}
-     * @private
-     */
-    static __name = null; // Will be assigned when calling createScript or registerScript.
-
-    /**
-     * @param {*} constructorFn - The constructor function of the script type.
-     * @returns {string} The script name.
-     * @private
-     */
-    static __getScriptName(constructorFn) {
-        if (typeof constructorFn !== 'function') return undefined;
-        if ('name' in Function.prototype) return constructorFn.name;
-        if (constructorFn === Function || constructorFn === Function.prototype.constructor) return 'Function';
-        const match = ('' + constructorFn).match(funcNameRegex);
-        return match ? match[1] : undefined;
-    }
-
-    /**
-     * Name of a Script Type.
-     *
-     * @type {string|null}
-     */
-    static get scriptName() {
-        return this.__name;
-    }
-
     /**
      * The interface to define attributes for Script Types. Refer to {@link ScriptAttributes}.
      *
@@ -310,9 +34,28 @@ class ScriptType extends EventHandler {
         return this.__attributes;
     }
 
+    /**
+     * @param {*} args - initialization arguments
+     * @protected
+     */
+    initScript(args) {
+        // super does not exist due to the way the class is instantiated
+        Script.prototype.initScript.call(this, args);
+        this.__attributes = { };
+        this.__attributesRaw = args.attributes || { }; // need at least an empty object to make sure default attributes are initialized
+    }
+
+    /**
+     * Expose initScript as initScriptType for backwards compatibility
+     * @param {*} args - Initialization arguments
+     * @protected
+     */
+    initScriptType(args) {
+        this.initScript(args);
+    }
+
     /**
      * @param {boolean} [force] - Set to true to force initialization of the attributes.
-     * @private
      */
     __initializeAttributes(force) {
         if (!force && !this.__attributesRaw)
@@ -358,41 +101,6 @@ class ScriptType extends EventHandler {
             this.prototype[key] = methods[key];
         }
     }
-
-    /**
-     * @function
-     * @name ScriptType#[initialize]
-     * @description Called when script is about to run for the first time.
-     */
-
-    /**
-     * @function
-     * @name ScriptType#[postInitialize]
-     * @description Called after all initialize methods are executed in the same tick or enabling chain of actions.
-     */
-
-    /**
-     * @function
-     * @name ScriptType#[update]
-     * @description Called for enabled (running state) scripts on each tick.
-     * @param {number} dt - The delta time in seconds since the last frame.
-     */
-
-    /**
-     * @function
-     * @name ScriptType#[postUpdate]
-     * @description Called for enabled (running state) scripts on each tick, after update.
-     * @param {number} dt - The delta time in seconds since the last frame.
-     */
-
-    /**
-     * @function
-     * @name ScriptType#[swap]
-     * @description Called when a ScriptType that already exists in the registry
-     * gets redefined. If the new ScriptType has a `swap` method in its prototype,
-     * then it will be executed to perform hot-reload at runtime.
-     * @param {ScriptType} old - Old instance of the scriptType to copy data to the new instance.
-     */
 }
 
 export { ScriptType };
diff --git a/src/framework/script/script.js b/src/framework/script/script.js
index d6bede296..3bc0f58a4 100644
--- a/src/framework/script/script.js
+++ b/src/framework/script/script.js
@@ -1,144 +1,344 @@
+import { Debug } from '../..//core/debug.js';
 import { EventHandler } from '../../core/event-handler.js';
-
-import { AppBase } from '../app-base.js';
-
-import { ScriptAttributes } from './script-attributes.js';
-import { ScriptType } from './script-type.js';
-import { ScriptTypes } from './script-types.js';
-
-const reservedScriptNames = new Set([
-    'system', 'entity', 'create', 'destroy', 'swap', 'move', 'data',
-    'scripts', '_scripts', '_scriptsIndex', '_scriptsData',
-    'enabled', '_oldState', 'onEnable', 'onDisable', 'onPostStateChange',
-    '_onSetEnabled', '_checkState', '_onBeforeRemove',
-    '_onInitializeAttributes', '_onInitialize', '_onPostInitialize',
-    '_onUpdate', '_onPostUpdate',
-    '_callbacks', '_callbackActive', 'has', 'get', 'on', 'off', 'fire', 'once', 'hasEvent'
-]);
-
-function getReservedScriptNames() {
-    return reservedScriptNames;
-}
+import { SCRIPT_INITIALIZE, SCRIPT_POST_INITIALIZE } from './constants.js';
 
 /**
- * Create and register a new {@link ScriptType}. It returns new class type (constructor function),
- * which is auto-registered to {@link ScriptRegistry} using its name. This is the main interface to
- * create Script Types, to define custom logic using JavaScript, that is used to create interaction
- * for entities.
+ * The `Script` class is the fundamental base class for all scripts within PlayCanvas. It provides
+ * the minimal interface required for a script to be compatible with both the Engine and the
+ * Editor.
+ *
+ * At its core, a script is simply a collection of methods that are called at various points in the Engine's lifecycle. These methods are:
+ *
+ * {@link Script#initialize} - Called once when the script is initialized
+ * {@link Script#postInitialize} - Called once after all scripts have been initialized
+ * {@link Script#update} - Called every frame, if the script is enabled
+ * {@link Script#postUpdate} - Called every frame, after all scripts have been updated
+ * {@link Script#swap} - Called when a script is redefined
  *
- * @param {string} name - Unique Name of a Script Type. If a Script Type with the same name has
- * already been registered and the new one has a `swap` method defined in its prototype, then it
- * will perform hot swapping of existing Script Instances on entities using this new Script Type.
- * Note: There is a reserved list of names that cannot be used, such as list below as well as some
- * starting from `_` (underscore): system, entity, create, destroy, swap, move, scripts, onEnable,
- * onDisable, onPostStateChange, has, on, off, fire, once, hasEvent.
- * @param {AppBase} [app] - Optional application handler, to choose which {@link ScriptRegistry}
- * to add a script to. By default it will use `Application.getApplication()` to get current
- * {@link AppBase}.
- * @returns {typeof ScriptType|null} A class type (constructor function) that inherits {@link ScriptType},
- * which the developer is meant to further extend by adding attributes and prototype methods.
- * Returns null if there was an error.
+ * These methods are entirely optional, but provide a useful way to manage the lifecycle of a script and perform any necessary setup and cleanup.
+ *
+ * Below is a simple example of a script that rotates an entity every frame.
  * @example
- * var Turning = pc.createScript('turn');
+ * ```javascript
+ * class EntityRotator extends Script {
+ *     update() {
+ *         this.entity.rotateLocal(0, 1, 0);
+ *     }
+ * }
+ * ```
+ *
+ * When this script is attached to an entity, the update will be called every frame, slowly rotating the entity around the Y-axis.
  *
- * // define 'speed' attribute that is available in Editor UI
- * Turning.attributes.add('speed', {
- *     type: 'number',
- *     default: 180,
- *     placeholder: 'deg/s'
- * });
+ * For more information on how to create scripts, see the [Scripting Overview](https://developer.playcanvas.com/user-manual/scripting/).
  *
- * // runs every tick
- * Turning.prototype.update = function (dt) {
- *     this.entity.rotate(0, this.speed * dt, 0);
- * };
  * @category Script
  */
-function createScript(name, app) {
-    if (reservedScriptNames.has(name))
-        throw new Error(`Script name '${name}' is reserved, please rename the script`);
+export class Script extends EventHandler {
+    /**
+     * Fired when a script instance becomes enabled.
+     *
+     * @event
+     * @example
+     * export class PlayerController extends Script {
+     *     initialize() {
+     *         this.on('enable', () => {
+     *             // Script Instance is now enabled
+     *         });
+     *     }
+     * };
+     */
+    static EVENT_ENABLE = 'enable';
 
-    const scriptType = function (args) {
-        EventHandler.prototype.initEventHandler.call(this);
-        ScriptType.prototype.initScriptType.call(this, args);
-    };
+    /**
+     * Fired when a script instance becomes disabled.
+     *
+     * @event
+     * @example
+     * export class PlayerController extends Script {
+     *     initialize() {
+     *         this.on('disable', () => {
+     *             // Script Instance is now disabled
+     *         });
+     *     }
+     * };
+     */
+    static EVENT_DISABLE = 'disable';
 
-    scriptType.prototype = Object.create(ScriptType.prototype);
-    scriptType.prototype.constructor = scriptType;
+    /**
+     * Fired when a script instance changes state to enabled or disabled. The handler is passed a
+     * boolean parameter that states whether the script instance is now enabled or disabled.
+     *
+     * @event
+     * @example
+     * export class PlayerController extends Script {
+     *     initialize() {
+     *         this.on('state', (enabled) => {
+     *             console.log(`Script Instance is now ${enabled ? 'enabled' : 'disabled'}`);
+     *         });
+     *     }
+     * };
+     */
+    static EVENT_STATE = 'state';
 
-    scriptType.extend = ScriptType.extend;
-    scriptType.attributes = new ScriptAttributes(scriptType);
+    /**
+     * Fired when a script instance is destroyed and removed from component.
+     *
+     * @event
+     * @example
+     * export class PlayerController extends Script {
+     *     initialize() {
+     *         this.on('destroy', () => {
+     *             // no longer part of the entity
+     *             // this is a good place to clean up allocated resources used by the script
+     *         });
+     *     }
+     * };
+     */
+    static EVENT_DESTROY = 'destroy';
 
-    registerScript(scriptType, name, app);
-    return scriptType;
-}
+    /**
+     * Fired when script attributes have changed. This event is available in two forms. They are as follows:
+     *
+     * 1. `attr` - Fired for any attribute change. The handler is passed the name of the attribute
+     * that changed, the value of the attribute before the change and the value of the attribute
+     * after the change.
+     * 2. `attr:[name]` - Fired for a specific attribute change. The handler is passed the value of
+     * the attribute before the change and the value of the attribute after the change.
+     *
+     * @event
+     * @example
+     * export class PlayerController extends Script {
+     *     initialize() {
+     *         this.on('attr', (name, newValue, oldValue) => {
+     *             console.log(`Attribute '${name}' changed from '${oldValue}' to '${newValue}'`);
+     *         });
+     *     }
+     * };
+     * @example
+     * export class PlayerController extends Script {
+     *     initialize() {
+     *         this.on('attr:speed', (newValue, oldValue) => {
+     *             console.log(`Attribute 'speed' changed from '${oldValue}' to '${newValue}'`);
+     *         });
+     *     }
+     * };
+     */
+    static EVENT_ATTR = 'attr';
 
-// Editor uses this - migrate to ScriptAttributes.reservedNames and delete this
-const reservedAttributes = {};
-ScriptAttributes.reservedNames.forEach((value, value2, set) => {
-    reservedAttributes[value] = 1;
-});
-createScript.reservedAttributes = reservedAttributes;
+    /**
+     * Fired when a script instance had an exception. The script instance will be automatically
+     * disabled. The handler is passed an Error object containing the details of the
+     * exception and the name of the method that threw the exception.
+     *
+     * @event
+     * @example
+     * export class PlayerController extends Script {
+     *     initialize() {
+     *         this.on('error', (err, method) => {
+     *             // caught an exception
+     *             console.log(err.stack);
+     *         });
+     *     }
+     * };
+     */
+    static EVENT_ERROR = 'error';
 
-/* eslint-disable jsdoc/check-examples */
-/**
- * Register a existing class type as a Script Type to {@link ScriptRegistry}. Useful when defining
- * a ES6 script class that extends {@link ScriptType} (see example).
- *
- * @param {typeof ScriptType} script - The existing class type (constructor function) to be
- * registered as a Script Type. Class must extend {@link ScriptType} (see example). Please note: A
- * class created using {@link createScript} is auto-registered, and should therefore not be pass
- * into {@link registerScript} (which would result in swapping out all related script instances).
- * @param {string} [name] - Optional unique name of the Script Type. By default it will use the
- * same name as the existing class. If a Script Type with the same name has already been registered
- * and the new one has a `swap` method defined in its prototype, then it will perform hot swapping
- * of existing Script Instances on entities using this new Script Type. Note: There is a reserved
- * list of names that cannot be used, such as list below as well as some starting from `_`
- * (underscore): system, entity, create, destroy, swap, move, scripts, onEnable, onDisable,
- * onPostStateChange, has, on, off, fire, once, hasEvent.
- * @param {AppBase} [app] - Optional application handler, to choose which {@link ScriptRegistry}
- * to register the script type to. By default it will use `Application.getApplication()` to get
- * current {@link AppBase}.
- * @example
- * // define a ES6 script class
- * class PlayerController extends pc.ScriptType {
- *
- *     initialize() {
- *         // called once on initialize
- *     }
- *
- *     update(dt) {
- *         // called each tick
- *     }
- * }
- *
- * // register the class as a script
- * pc.registerScript(PlayerController);
- *
- * // declare script attributes (Must be after pc.registerScript())
- * PlayerController.attributes.add('attribute1', {type: 'number'});
- * @category Script
- */
-function registerScript(script, name, app) {
-    if (typeof script !== 'function')
-        throw new Error(`script class: '${script}' must be a constructor function (i.e. class).`);
+    /**
+     * The {@link AppBase} that the instance of this type belongs to.
+     *
+     * @type {import('../app-base.js').AppBase}
+     */
+    app;
+
+    /**
+     * The {@link Entity} that the instance of this type belongs to.
+     *
+     * @type {import('../entity.js').Entity}
+     */
+    entity;
+
+    /** @private */
+    _enabled;
+
+    /** @private */
+    _enabledOld;
 
-    if (!(script.prototype instanceof ScriptType))
-        throw new Error(`script class: '${ScriptType.__getScriptName(script)}' does not extend pc.ScriptType.`);
+    /** @private */
+    _initialized;
 
-    name = name || script.__name || ScriptType.__getScriptName(script);
+    /** @private */
+    _postInitialized;
 
-    if (reservedScriptNames.has(name))
-        throw new Error(`script name: '${name}' is reserved, please change script name`);
+    /** @private */
+    __destroyed;
 
-    script.__name = name;
+    /** @private */
+    __scriptType;
 
-    // add to scripts registry
-    const registry = app ? app.scripts : AppBase.getApplication().scripts;
-    registry.add(script);
+    /**
+     * The order in the script component that the methods of this script instance will run
+     * relative to other script instances in the component.
+     *
+     * @type {number}
+     * @private
+     */
+    __executionOrder;
 
-    ScriptTypes.push(script);
+    /**
+     * Create a new Script instance.
+     *
+     * @param {object} args - The input arguments object.
+     * @param {import('../app-base.js').AppBase} args.app - The {@link AppBase} that is running the
+     * script.
+     * @param {import('../entity.js').Entity} args.entity - The {@link Entity} that the script is
+     * attached to.
+     */
+    constructor(args) {
+        super();
+        this.initScript(args);
+    }
+
+    /**
+     * True if the instance of this type is in running state. False when script is not running,
+     * because the Entity or any of its parents are disabled or the {@link ScriptComponent} is
+     * disabled or the Script Instance is disabled. When disabled no update methods will be called
+     * on each tick. initialize and postInitialize methods will run once when the script instance
+     * is in `enabled` state during app tick.
+     *
+     * @type {boolean}
+     */
+    set enabled(value) {
+        this._enabled = !!value;
+
+        if (this.enabled === this._enabledOld) return;
+
+        this._enabledOld = this.enabled;
+        this.fire(this.enabled ? 'enable' : 'disable');
+        this.fire('state', this.enabled);
+
+        // initialize script if not initialized yet and script is enabled
+        if (!this._initialized && this.enabled) {
+            this._initialized = true;
+
+            this.fire('preInitialize');
+
+            if (this.initialize)
+                this.entity.script._scriptMethod(this, SCRIPT_INITIALIZE);
+        }
+
+        // post initialize script if not post initialized yet and still enabled
+        // (initialize might have disabled the script so check this.enabled again)
+        // Warning: Do not do this if the script component is currently being enabled
+        // because in this case post initialize must be called after all the scripts
+        // in the script component have been initialized first
+        if (this._initialized && !this._postInitialized && this.enabled && !this.entity.script._beingEnabled) {
+            this._postInitialized = true;
+
+            if (this.postInitialize)
+                this.entity.script._scriptMethod(this, SCRIPT_POST_INITIALIZE);
+        }
+    }
+
+    get enabled() {
+        return this._enabled && !this._destroyed && this.entity.script.enabled && this.entity.enabled;
+    }
+
+    /**
+     * @typedef {object} ScriptInitializationArgs
+     * @property {boolean} [enabled] - True if the script instance is in running state.
+     * @property {import('../app-base.js').AppBase} app - The {@link AppBase} that is running the script.
+     * @property {import('../entity.js').Entity} entity - The {@link Entity} that the script is attached to.
+     */
+
+    /**
+     * @param {ScriptInitializationArgs} args - The input arguments object.
+     * @protected
+     */
+    initScript(args) {
+        const script = this.constructor; // get script type, i.e. function (class)
+        Debug.assert(args && args.app && args.entity, `script [${script.__name}] has missing arguments in constructor`);
+
+        this.app = args.app;
+        this.entity = args.entity;
+
+        this._enabled = typeof args.enabled === 'boolean' ? args.enabled : true;
+        this._enabledOld = this.enabled;
+
+        this.__destroyed = false;
+
+        this.__scriptType = script;
+        this.__executionOrder = -1;
+    }
+
+    /**
+     * Name of a Script Type.
+     *
+     * @type {string}
+     * @private
+     */
+    static __name = null; // Will be assigned when calling createScript or registerScript.
+
+    /**
+     * @param {*} constructorFn - The constructor function of the script type.
+     * @returns {string} The script name.
+     * @private
+     */
+    static __getScriptName = getScriptName;
+
+    /**
+     * Name of a Script Type.
+     *
+     * @type {string|null}
+     */
+    static get scriptName() {
+        return this.__name;
+    }
+
+    /**
+     * @function
+     * @name Script#[initialize]
+     * @description Called when script is about to run for the first time.
+     */
+
+    /**
+     * @function
+     * @name Script#[postInitialize]
+     * @description Called after all initialize methods are executed in the same tick or enabling chain of actions.
+     */
+
+    /**
+     * @function
+     * @name Script#[update]
+     * @description Called for enabled (running state) scripts on each tick.
+     * @param {number} dt - The delta time in seconds since the last frame.
+     */
+
+    /**
+     * @function
+     * @name Script#[postUpdate]
+     * @description Called for enabled (running state) scripts on each tick, after update.
+     * @param {number} dt - The delta time in seconds since the last frame.
+     */
+
+    /**
+     * @function
+     * @name Script#[swap]
+     * @description Called when a Script that already exists in the registry gets redefined. If the
+     * new Script has a `swap` method, then it will be executed to perform hot-reload at runtime.
+     * @param {Script} old - Old instance of the scriptType to copy data to the new instance.
+     */
 }
-/* eslint-enable jsdoc/check-examples */
 
-export { createScript, registerScript, getReservedScriptNames };
+const funcNameRegex = new RegExp('^\\s*function(?:\\s|\\s*\\/\\*.*\\*\\/\\s*)+([^\\(\\s\\/]*)\\s*');
+
+/**
+ * @param {Function} constructorFn - The constructor function of the script type.
+ * @returns {string|undefined} The script name.
+ */
+export function getScriptName(constructorFn) {
+    if (typeof constructorFn !== 'function') return undefined;
+    if ('name' in Function.prototype) return constructorFn.name;
+    if (constructorFn === Function || constructorFn === Function.prototype.constructor) return 'Function';
+    const match = ('' + constructorFn).match(funcNameRegex);
+    return match ? match[1] : undefined;
+}
diff --git a/src/index.js b/src/index.js
index 1932181b0..4a7052d5e 100644
--- a/src/index.js
+++ b/src/index.js
@@ -356,10 +356,11 @@ export { ElementInput, ElementInputEvent, ElementMouseEvent, ElementSelectEvent,
 export { JsonStandardMaterialParser } from './framework/parsers/material/json-standard-material.js';
 
 // FRAMEWORK /SCRIPTS
-export { createScript, registerScript, getReservedScriptNames } from './framework/script/script.js';
+export { createScript, registerScript, getReservedScriptNames } from './framework/script/script-create.js';
 export { ScriptAttributes } from './framework/script/script-attributes.js';
 export { ScriptRegistry } from './framework/script/script-registry.js';
 export { ScriptType } from './framework/script/script-type.js';
+export { Script } from './framework/script/script.js';
 
 // FRAMEWORK / LOCALIZATION
 export { I18n } from './framework/i18n/i18n.js';
diff --git a/src/scene/gsplat/gsplat-data.js b/src/scene/gsplat/gsplat-data.js
index 6cfc2f81c..30a47af84 100644
--- a/src/scene/gsplat/gsplat-data.js
+++ b/src/scene/gsplat/gsplat-data.js
@@ -182,28 +182,10 @@ class GSplatData {
 
     // /**
     //  * @param {import('./ply-reader').PlyElement[]} elements - The elements.
-    //  * @param {boolean} [performZScale] - Whether to perform z scaling.
-    //  * @param {object} [options] - The options.
-    //  * @param {boolean} [options.performZScale] - Whether to perform z scaling.
-    //  * @param {boolean} [options.reorder] - Whether to reorder the data.
     //  */
-    constructor(elements, options = {}) {
+    constructor(elements) {
         this.elements = elements;
-
         this.numSplats = this.getElement('vertex').count;
-
-        if (!this.isCompressed) {
-            if (options.performZScale ?? true) {
-                mat4.setScale(-1, -1, 1);
-                this.transform(mat4);
-            }
-
-            // reorder uncompressed splats in morton order for better memory access
-            // efficiency during rendering
-            if (options.reorder ?? true) {
-                this.reorderData();
-            }
-        }
     }
 
     /**
@@ -584,10 +566,7 @@ class GSplatData {
                     storage: data[name]
                 };
             })
-        }], {
-            performZScale: false,
-            reorder: false
-        });
+        }]);
     }
 
     calcMortonOrder() {
@@ -623,40 +602,66 @@ class GSplatData {
         const { min: minY, max: maxY } = calcMinMax(y);
         const { min: minZ, max: maxZ } = calcMinMax(z);
 
-        const sizeX = 1024 / (maxX - minX);
-        const sizeY = 1024 / (maxY - minY);
-        const sizeZ = 1024 / (maxZ - minZ);
+        const sizeX = minX === maxX ? 0 : 1024 / (maxX - minX);
+        const sizeY = minY === maxY ? 0 : 1024 / (maxY - minY);
+        const sizeZ = minZ === maxZ ? 0 : 1024 / (maxZ - minZ);
 
-        const morton = new Uint32Array(this.numSplats);
+        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);
-            morton[i] = encodeMorton3(ix, iy, iz);
+            const code = encodeMorton3(ix, iy, iz);
+
+            const val = codes.get(code);
+            if (val) {
+                val.push(i);
+            } else {
+                codes.set(code, [i]);
+            }
         }
 
-        // generate indices
+        const keys = Array.from(codes.keys()).sort((a, b) => a - b);
         const indices = new Uint32Array(this.numSplats);
-        for (let i = 0; i < this.numSplats; i++) {
-            indices[i] = i;
+        let idx = 0;
+
+        for (let i = 0; i < keys.length; ++i) {
+            const val = codes.get(keys[i]);
+            for (let j = 0; j < val.length; ++j) {
+                indices[idx++] = val[j];
+            }
         }
-        // order splats by morton code
-        indices.sort((a, b) => morton[a] - morton[b]);
 
         return indices;
     }
 
     // reorder the splat data to aid in better gpu memory access at render time
-    reorderData() {
-        const order = this.calcMortonOrder();
+    reorder(order) {
+        const cache = new Map();
+
+        const getStorage = (size) => {
+            if (cache.has(size)) {
+                const buffer = cache.get(size);
+                cache.delete(size);
+                return buffer;
+            }
+
+            return new ArrayBuffer(size);
+        };
+
+        const returnStorage = (buffer) => {
+            cache.set(buffer.byteLength, buffer);
+        };
 
         const reorder = (data) => {
-            const result = new data.constructor(data.length);
+            const result = new data.constructor(getStorage(data.byteLength));
 
             for (let i = 0; i < order.length; i++) {
                 result[i] = data[order[i]];
             }
 
+            returnStorage(data.buffer);
+
             return result;
         };
 
@@ -668,6 +673,11 @@ class GSplatData {
             });
         });
     }
+
+    // reorder the splat data to aid in better gpu memory access at render time
+    reorderData() {
+        this.reorder(this.calcMortonOrder());
+    }
 }
 
 export { GSplatData };
diff --git a/src/scene/gsplat/gsplat-material.js b/src/scene/gsplat/gsplat-material.js
index 31d2f9652..ee15e2264 100644
--- a/src/scene/gsplat/gsplat-material.js
+++ b/src/scene/gsplat/gsplat-material.js
@@ -27,6 +27,8 @@ const splatMainFS = `
  * @property {string} [vertex] - Custom vertex shader, see SPLAT MANY example.
  * @property {string} [fragment] - Custom fragment shader, see SPLAT MANY example.
  * @property {string} [dither] - Opacity dithering enum.
+ *
+ * @ignore
  */
 
 /**
diff --git a/src/scene/gsplat/shader-generator-gsplat.js b/src/scene/gsplat/shader-generator-gsplat.js
index 8a6949210..9b54f9786 100644
--- a/src/scene/gsplat/shader-generator-gsplat.js
+++ b/src/scene/gsplat/shader-generator-gsplat.js
@@ -16,7 +16,10 @@ const splatCoreVS = /* glsl */ `
 
     varying vec2 texCoord;
     varying vec4 color;
-    varying float id;
+
+    #ifndef DITHER_NONE
+        varying float id;
+    #endif
 
     uniform vec4 tex_params;
     uniform sampler2D splatColor;
@@ -86,7 +89,10 @@ const splatCoreVS = /* glsl */ `
             return vec4(0.0, 0.0, 2.0, 1.0);
         }
 
-        id = float(splatId);
+        #ifndef DITHER_NONE
+            id = float(splatId);
+        #endif
+
         color = getColor();
 
         mat3 Vrk = mat3(
@@ -136,10 +142,13 @@ const splatCoreVS = /* glsl */ `
     }
 `;
 
-const splatCoreFS = /* glsl_ */ `
+const splatCoreFS = /* glsl */ `
     varying vec2 texCoord;
     varying vec4 color;
-    varying float id;
+
+    #ifndef DITHER_NONE
+        varying float id;
+    #endif
 
     #ifdef PICK_PASS
         uniform vec4 uColor;


Test patch:
diff --git a/test/framework/entity.test.mjs b/test/framework/entity.test.mjs
index 1cd638507..44d162be1 100644
--- a/test/framework/entity.test.mjs
+++ b/test/framework/entity.test.mjs
@@ -1,4 +1,4 @@
-import { createScript } from '../../src/framework/script/script.js';
+import { createScript } from '../../src/framework/script/script-create.js';
 import { Color } from '../../src/core/math/color.js';
 
 import { AnimComponent } from '../../src/framework/components/anim/component.js';