Repository: playcanvas/engine
Pull request #6351 (2024-05-10 14:57:58)

Added docs for supported HDR formats
- this is always pretty unclear and complicated, and this tries to explain is plainly now that we only need to handle WebGL2 and WebGPU:

<img width="970" alt="Screenshot 2024-05-10 at 16 35 16" src="https://github.com/playcanvas/engine/assets/59932779/140abe1c-3d2d-4e22-bddc-b3086e64b742">

<img width="978" alt="Screenshot 2024-05-10 at 16 14 12" src="https://github.com/playcanvas/engine/assets/59932779/831f3950-1a57-4567-a417-32662c5e0190">




Patch:
diff --git a/examples/src/examples/compute/particles.example.mjs b/examples/src/examples/compute/particles.example.mjs
index deb05191a..ddb2175f7 100644
--- a/examples/src/examples/compute/particles.example.mjs
+++ b/examples/src/examples/compute/particles.example.mjs
@@ -220,11 +220,6 @@ assetListLoader.load(() => {
             new pc.UniformFormat('matrix_model', pc.UNIFORMTYPE_MAT4)
         ]),
         meshBindGroupFormat: new pc.BindGroupFormat(app.graphicsDevice, [
-            // uniforms
-            new pc.BindUniformBufferFormat(
-                pc.UNIFORM_BUFFER_DEFAULT_SLOT_NAME,
-                pc.SHADERSTAGE_VERTEX | pc.SHADERSTAGE_FRAGMENT
-            ),
             // particle storage buffer in read-only mode
             new pc.BindStorageBufferFormat('particles', pc.SHADERSTAGE_VERTEX | pc.SHADERSTAGE_FRAGMENT, true)
         ])
@@ -232,6 +227,7 @@ assetListLoader.load(() => {
 
     // material to render the particles
     const material = new pc.Material();
+    material.name = 'ParticleRenderingMaterial';
     material.shader = new pc.Shader(app.graphicsDevice, shaderDefinition);
 
     // index buffer - two triangles (6 indices) per particle using 4 vertices
@@ -254,7 +250,7 @@ assetListLoader.load(() => {
     const meshInstance = new pc.MeshInstance(mesh, material);
     meshInstance.cull = false; // disable culling as we did not supply custom aabb for the mesh instance
 
-    const entity = new pc.Entity();
+    const entity = new pc.Entity('ParticleRenderingEntity');
     entity.addComponent('render', {
         meshInstances: [meshInstance]
     });
diff --git a/examples/src/examples/compute/particles.shader-rendering.wgsl b/examples/src/examples/compute/particles.shader-rendering.wgsl
index 4f6da1619..36c564279 100644
--- a/examples/src/examples/compute/particles.shader-rendering.wgsl
+++ b/examples/src/examples/compute/particles.shader-rendering.wgsl
@@ -8,9 +8,9 @@ struct ub_view {
     matrix_viewProjection : mat4x4f
 }
 
-@group(0) @binding(0) var<uniform> uvMesh : ub_mesh;
-@group(0) @binding(1) var<storage, read> particles: array<Particle>;
-@group(1) @binding(0) var<uniform> ubView : ub_view;
+@group(2) @binding(0) var<uniform> ubMesh : ub_mesh;
+@group(1) @binding(0) var<storage, read> particles: array<Particle>;
+@group(0) @binding(0) var<uniform> ubView : ub_view;
 
 // quad vertices - used to expand the particles into quads
 var<private> pos : array<vec2f, 4> = array<vec2f, 4>(
diff --git a/examples/src/examples/graphics/wgsl-shader.example.mjs b/examples/src/examples/graphics/wgsl-shader.example.mjs
index 9b23bb66b..76162178a 100644
--- a/examples/src/examples/graphics/wgsl-shader.example.mjs
+++ b/examples/src/examples/graphics/wgsl-shader.example.mjs
@@ -56,12 +56,7 @@ const shaderDefinition = {
         new pc.UniformFormat('matrix_model', pc.UNIFORMTYPE_MAT4),
         new pc.UniformFormat('amount', pc.UNIFORMTYPE_FLOAT)
     ]),
-    meshBindGroupFormat: new pc.BindGroupFormat(app.graphicsDevice, [
-        new pc.BindUniformBufferFormat(
-            pc.UNIFORM_BUFFER_DEFAULT_SLOT_NAME,
-            pc.SHADERSTAGE_VERTEX | pc.SHADERSTAGE_FRAGMENT
-        )
-    ])
+    meshBindGroupFormat: new pc.BindGroupFormat(app.graphicsDevice, [])
 };
 const shader = new pc.Shader(app.graphicsDevice, shaderDefinition);
 
diff --git a/examples/src/examples/graphics/wgsl-shader.shader.wgsl b/examples/src/examples/graphics/wgsl-shader.shader.wgsl
index d15612a3a..63953a6c3 100644
--- a/examples/src/examples/graphics/wgsl-shader.shader.wgsl
+++ b/examples/src/examples/graphics/wgsl-shader.shader.wgsl
@@ -12,8 +12,8 @@ struct VertexOutput {
     @location(0) fragPosition: vec4f,
 }
 
-@group(0) @binding(0) var<uniform> uvMesh : ub_mesh;
-@group(1) @binding(0) var<uniform> ubView : ub_view;
+@group(2) @binding(0) var<uniform> uvMesh : ub_mesh;
+@group(0) @binding(0) var<uniform> ubView : ub_view;
 
 @vertex
 fn vertexMain(@location(0) position : vec4f) -> VertexOutput {
diff --git a/src/deprecated/deprecated.js b/src/deprecated/deprecated.js
index 95d601edf..910c0d805 100644
--- a/src/deprecated/deprecated.js
+++ b/src/deprecated/deprecated.js
@@ -126,9 +126,6 @@ import { basisInitialize } from '../framework/handlers/basis.js';
 import { LitShader } from '../scene/shader-lib/programs/lit-shader.js';
 import { Geometry } from '../scene/geometry/geometry.js';
 
-// ScriptType alias
-export { Script as ScriptType } from '../framework/script/script.js';
-
 // CORE
 export const LINEBATCH_WORLD = 0;
 export const LINEBATCH_OVERLAY = 1;
diff --git a/src/framework/components/script/component.js b/src/framework/components/script/component.js
index cc918e721..c182759bf 100644
--- a/src/framework/components/script/component.js
+++ b/src/framework/components/script/component.js
@@ -19,7 +19,7 @@ import { Entity } from '../../entity.js';
  */
 class ScriptComponent extends Component {
     /**
-     * Fired when a {@link Script} instance is created and attached to the script component.
+     * 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:
      *
      * 1. `create` - Fired when a script instance is created. The name of the script type and the
@@ -40,7 +40,7 @@ class ScriptComponent extends Component {
     static EVENT_CREATE = 'create';
 
     /**
-     * Fired when a {@link Script} instance is destroyed and removed from the script component.
+     * Fired when a {@link ScriptType} instance is destroyed and removed from the script component.
      * This event is available in two forms. They are as follows:
      *
      * 1. `destroy` - Fired when a script instance is destroyed. The name of the script type and
@@ -109,7 +109,7 @@ class ScriptComponent extends Component {
     static EVENT_STATE = 'state';
 
     /**
-     * Fired when the index of a {@link Script} instance is changed in the script component.
+     * Fired when the index of a {@link ScriptType} instance is changed in the script component.
      * This event is available in two forms. They are as follows:
      *
      * 1. `move` - Fired when a script instance is moved. The name of the script type, the script
@@ -130,7 +130,7 @@ class ScriptComponent extends Component {
     static EVENT_MOVE = 'move';
 
     /**
-     * Fired when a {@link Script} instance had an exception. The handler is passed the script
+     * Fired when a {@link ScriptType} instance had an exception. The handler is passed the script
      * instance, the exception and the method name that the exception originated from.
      *
      * @event
@@ -154,7 +154,7 @@ class ScriptComponent extends Component {
         /**
          * Holds all script instances for this component.
          *
-         * @type {import('../../script/script.js').Script[]}
+         * @type {import('../../script/script-type.js').ScriptType[]}
          * @private
          */
         this._scripts = [];
@@ -191,7 +191,7 @@ class ScriptComponent extends Component {
      * An 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.js').Script[]}
+     * @type {import('../../script/script-type.js').ScriptType[]}
      */
     set scripts(value) {
         this._scriptsData = value;
@@ -546,8 +546,8 @@ class ScriptComponent extends Component {
     /**
      * Detect if script is attached to an entity.
      *
-     * @param {string|typeof import('../../script/script.js').Script} nameOrType - The
-     * name or type of {@link Script}.
+     * @param {string|typeof import('../../script/script-type.js').ScriptType} nameOrType - The
+     * name or type of {@link ScriptType}.
      * @returns {boolean} If script is attached to an entity.
      * @example
      * if (entity.script.has('playerController')) {
@@ -570,9 +570,9 @@ class ScriptComponent extends Component {
     /**
      * Get a script instance (if attached).
      *
-     * @param {string|typeof import('../../script/script.js').Script} nameOrType - The
-     * name or type of {@link Script}.
-     * @returns {import('../../script/script.js').Script|null} If script is attached, the
+     * @param {string|typeof import('../../script/script-type.js').ScriptType} nameOrType - The
+     * name or type of {@link ScriptType}.
+     * @returns {import('../../script/script-type.js').ScriptType|null} If script is attached, the
      * instance is returned. Otherwise null is returned.
      * @example
      * const controller = entity.script.get('playerController');
@@ -594,8 +594,8 @@ class ScriptComponent extends Component {
     /**
      * Create a script instance and attach to an entity script component.
      *
-     * @param {string|typeof import('../../script/script.js').Script} nameOrType - The
-     * name or type of {@link Script}.
+     * @param {string|typeof import('../../script/script-type.js').ScriptType} nameOrType - The
+     * name or type of {@link ScriptType}.
      * @param {object} [args] - Object with arguments for a script.
      * @param {boolean} [args.enabled] - If script instance is enabled after creation. Defaults to
      * true.
@@ -605,9 +605,9 @@ class ScriptComponent extends Component {
      * script and attributes must be initialized manually. Defaults to false.
      * @param {number} [args.ind] - The index where to insert the script instance at. Defaults to
      * -1, which means append it at the end.
-     * @returns {import('../../script/script.js').Script|null} Returns an instance of a
-     * {@link Script} if successfully attached to an entity, or null if it failed because a
-     * script with a same name has already been added or if the {@link Script} cannot be found
+     * @returns {import('../../script/script-type.js').ScriptType|null} Returns an instance of a
+     * {@link ScriptType} if successfully attached to an entity, or null if it failed because a
+     * script with a same name has already been added or if the {@link ScriptType} cannot be found
      * by name in the {@link ScriptRegistry}.
      * @example
      * entity.script.create('playerController', {
@@ -699,8 +699,8 @@ class ScriptComponent extends Component {
     /**
      * Destroy the script instance that is attached to an entity.
      *
-     * @param {string|typeof import('../../script/script.js').Script} nameOrType - The
-     * name or type of {@link Script}.
+     * @param {string|typeof import('../../script/script-type.js').ScriptType} nameOrType - The
+     * name or type of {@link ScriptType}.
      * @returns {boolean} If it was successfully destroyed.
      * @example
      * entity.script.destroy('playerController');
@@ -756,8 +756,8 @@ class ScriptComponent extends Component {
     /**
      * Swap the script instance.
      *
-     * @param {string|typeof import('../../script/script.js').Script} nameOrType - The
-     * name or type of {@link Script}.
+     * @param {string|typeof import('../../script/script-type.js').ScriptType} nameOrType - The
+     * name or type of {@link ScriptType}.
      * @returns {boolean} If it was successfully swapped.
      * @private
      */
@@ -927,8 +927,8 @@ class ScriptComponent extends Component {
     /**
      * Move script instance to different position to alter update order of scripts within entity.
      *
-     * @param {string|typeof import('../../script/script.js').Script} nameOrType - The
-     * name or type of {@link Script}.
+     * @param {string|typeof import('../../script/script-type.js').ScriptType} nameOrType - The
+     * name or type of {@link ScriptType}.
      * @param {number} ind - New position index.
      * @returns {boolean} If it was successfully moved.
      * @example
diff --git a/src/framework/entity.js b/src/framework/entity.js
index e4ca2cb4b..08b808127 100644
--- a/src/framework/entity.js
+++ b/src/framework/entity.js
@@ -400,8 +400,8 @@ class Entity extends GraphNode {
     /**
      * Search the entity and all of its descendants for the first script instance of specified type.
      *
-     * @param {string|typeof import('./script/script.js').Script} nameOrType - The name or type of {@link Script}.
-     * @returns {import('./script/script.js').Script|undefined} A script instance of specified type, if the entity or any of its descendants
+     * @param {string|typeof import('./script/script-type.js').ScriptType} nameOrType - The name or type of {@link ScriptType}.
+     * @returns {import('./script/script-type.js').ScriptType|undefined} A script instance of specified type, if the entity or any of its descendants
      * has one. Returns undefined otherwise.
      * @example
      * // Get the first found "playerController" instance in the hierarchy tree that starts with this entity
@@ -415,8 +415,8 @@ class Entity extends GraphNode {
     /**
      * Search the entity and all of its descendants for all script instances of specified type.
      *
-     * @param {string|typeof import('./script/script.js').Script} nameOrType - The name or type of {@link Script}.
-     * @returns {import('./script/script.js').Script[]} All script instances of specified type in the entity or any of its
+     * @param {string|typeof import('./script/script-type.js').ScriptType} nameOrType - The name or type of {@link ScriptType}.
+     * @returns {import('./script/script-type.js').ScriptType[]} All script instances of specified type in the entity or any of its
      * descendants. Returns empty array if none found.
      * @example
      * // Get all "playerController" instances in the hierarchy tree that starts with this entity
diff --git a/src/framework/handlers/script.js b/src/framework/handlers/script.js
index c63caab4e..d77f0f71f 100644
--- a/src/framework/handlers/script.js
+++ b/src/framework/handlers/script.js
@@ -1,8 +1,8 @@
 import { platform } from '../../core/platform.js';
 import { script } from '../script.js';
-import { Script } from '../script/script.js';
+import { ScriptType } from '../script/script-type.js';
 import { ScriptTypes } from '../script/script-types.js';
-import { registerScript } from '../script/script-create.js';
+import { registerScript } from '../script/script.js';
 import { ResourceLoader } from './loader.js';
 
 import { ResourceHandler } from './handler.js';
@@ -154,7 +154,7 @@ class ScriptHandler extends ResourceHandler {
 
             for (const key in module) {
                 const scriptClass = module[key];
-                const extendsScriptType = scriptClass.prototype instanceof Script;
+                const extendsScriptType = scriptClass.prototype instanceof ScriptType;
 
                 if (extendsScriptType) {
 
diff --git a/src/framework/script/script-attributes.js b/src/framework/script/script-attributes.js
index 7a718b401..0d0dc1d90 100644
--- a/src/framework/script/script-attributes.js
+++ b/src/framework/script/script-attributes.js
@@ -150,8 +150,8 @@ function rawToValue(app, args, value, old) {
 
 /**
  * Container of Script Attribute definitions. Implements an interface to add/remove attributes and
- * store their definition for a {@link Script}. Note: An instance of ScriptAttributes is
- * created automatically by each {@link Script}.
+ * store their definition for a {@link ScriptType}. Note: An instance of ScriptAttributes is
+ * created automatically by each {@link ScriptType}.
  *
  * @category Script
  */
@@ -159,7 +159,7 @@ class ScriptAttributes {
     /**
      * Create a new ScriptAttributes instance.
      *
-     * @param {typeof import('./script.js').Script} scriptType - Script Type that attributes relate to.
+     * @param {typeof import('./script-type.js').ScriptType} scriptType - Script Type that attributes relate to.
      */
     constructor(scriptType) {
         this.scriptType = scriptType;
diff --git a/src/framework/script/script-create.js b/src/framework/script/script-create.js
deleted file mode 100644
index b463156e2..000000000
--- a/src/framework/script/script-create.js
+++ /dev/null
@@ -1,148 +0,0 @@
-import { Debug } from '../../core/debug.js';
-
-import { script } from '../script.js';
-import { AppBase } from '../app-base.js';
-
-import { ScriptAttributes } from './script-attributes.js';
-import { Script } from './script.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;
-}
-
-/**
- * Create and register a new {@link Script}. 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 Script|null} A class type (constructor function) that inherits {@link Script},
- * 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 (script.legacy) {
-        Debug.error('This project is using the legacy script system. You cannot call pc.createScript().');
-        return null;
-    }
-
-    if (reservedScriptNames.has(name))
-        throw new Error(`Script name '${name}' is reserved, please rename the script`);
-
-    class ScriptWithAttributes extends Script {
-        attributes = new ScriptAttributes(ScriptWithAttributes);
-    }
-
-    registerScript(ScriptWithAttributes, name, app);
-    return ScriptWithAttributes;
-}
-
-// 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 Script} (see example).
- *
- * @param {typeof Script} script - The existing class type (constructor function) to be
- * registered as a Script Type. Class must extend {@link Script} (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.Script {
- *
- *     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 (script.legacy) {
-        Debug.error('This project is using the legacy script system. You cannot call pc.registerScript().');
-        return;
-    }
-
-    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: '${Script.__getScriptName(script)}' does not extend pc.Script.`);
-
-    name = name || script.__name || Script.__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, script.legacy);
-}
-/* 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 78db4b531..12f08201c 100644
--- a/src/framework/script/script-registry.js
+++ b/src/framework/script/script-registry.js
@@ -1,7 +1,7 @@
 import { EventHandler } from '../../core/event-handler.js';
 
 /**
- * Container for all {@link Script}s that are available to this application. Note that
+ * Container for all {@link ScriptType}s that are available to this application. Note that
  * PlayCanvas scripts can access the Script Registry from inside the application with
  * {@link AppBase#scripts}.
  *
@@ -9,13 +9,13 @@ import { EventHandler } from '../../core/event-handler.js';
  */
 class ScriptRegistry extends EventHandler {
     /**
-     * @type {Object<string, typeof import('./script.js').Script>}
+     * @type {Object<string, typeof import('./script-type.js').ScriptType>}
      * @private
      */
     _scripts = {};
 
     /**
-     * @type {typeof import('./script.js').Script[]}
+     * @type {typeof import('./script-type.js').ScriptType[]}
      * @private
      */
     _list = [];
@@ -37,12 +37,12 @@ class ScriptRegistry extends EventHandler {
     }
 
     /**
-     * Add {@link Script} to registry. Note: when {@link createScript} is called, it will add
-     * the {@link Script} to the registry automatically. If a script already exists in
+     * 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
      * registry, and the new script has a `swap` method defined, it will perform code hot swapping
      * automatically in async manner.
      *
-     * @param {typeof import('./script.js').Script} script - Script Type that is created
+     * @param {typeof import('./script-type.js').ScriptType} script - Script Type that is created
      * using {@link createScript}.
      * @returns {boolean} True if added for the first time or false if script already exists.
      * @example
@@ -146,10 +146,10 @@ class ScriptRegistry extends EventHandler {
     }
 
     /**
-     * Remove {@link Script}.
+     * Remove {@link ScriptType}.
      *
-     * @param {string|typeof import('./script.js').Script} nameOrType - The name or type
-     * of {@link Script}.
+     * @param {string|typeof import('./script-type.js').ScriptType} nameOrType - The name or type
+     * of {@link ScriptType}.
      * @returns {boolean} True if removed or False if already not in registry.
      * @example
      * app.scripts.remove('playerController');
@@ -178,10 +178,10 @@ class ScriptRegistry extends EventHandler {
     }
 
     /**
-     * Get {@link Script} by name.
+     * Get {@link ScriptType} by name.
      *
-     * @param {string} name - Name of a {@link Script}.
-     * @returns {typeof import('./script.js').Script} The Script Type if it exists in the
+     * @param {string} name - Name of a {@link ScriptType}.
+     * @returns {typeof import('./script-type.js').ScriptType} The Script Type if it exists in the
      * registry or null otherwise.
      * @example
      * var PlayerController = app.scripts.get('playerController');
@@ -191,11 +191,11 @@ class ScriptRegistry extends EventHandler {
     }
 
     /**
-     * Check if a {@link Script} with the specified name is in the registry.
+     * Check if a {@link ScriptType} with the specified name is in the registry.
      *
-     * @param {string|typeof import('./script.js').Script} nameOrType - The name or type
-     * of {@link Script}.
-     * @returns {boolean} True if {@link Script} is in registry.
+     * @param {string|typeof import('./script-type.js').ScriptType} nameOrType - The name or type
+     * of {@link ScriptType}.
+     * @returns {boolean} True if {@link ScriptType} is in registry.
      * @example
      * if (app.scripts.has('playerController')) {
      *     // playerController is in pc.ScriptRegistry
@@ -212,9 +212,9 @@ class ScriptRegistry extends EventHandler {
     }
 
     /**
-     * Get list of all {@link Script}s from registry.
+     * Get list of all {@link ScriptType}s from registry.
      *
-     * @returns {Array<typeof import('./script.js').Script>} list of all {@link Script}s
+     * @returns {Array<typeof import('./script-type.js').ScriptType>} list of all {@link ScriptType}s
      * in registry.
      * @example
      * // logs array of all Script Type names available in registry
diff --git a/src/framework/script/script-type.js b/src/framework/script/script-type.js
new file mode 100644
index 000000000..3a1ff14a3
--- /dev/null
+++ b/src/framework/script/script-type.js
@@ -0,0 +1,393 @@
+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*');
+
+/**
+ * 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.
+ *
+ * @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;
+
+    /** @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);
+    }
+
+    /**
+     * 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.__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);
+        }
+    }
+
+    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}.
+     *
+     * @type {ScriptAttributes}
+     * @example
+     * var PlayerController = pc.createScript('playerController');
+     *
+     * PlayerController.attributes.add('speed', {
+     *     type: 'number',
+     *     title: 'Speed',
+     *     placeholder: 'km/h',
+     *     default: 22.2
+     * });
+     */
+    static get attributes() {
+        if (!this.hasOwnProperty('__attributes')) this.__attributes = new ScriptAttributes(this);
+        return this.__attributes;
+    }
+
+    /**
+     * @param {boolean} [force] - Set to true to force initialization of the attributes.
+     * @private
+     */
+    __initializeAttributes(force) {
+        if (!force && !this.__attributesRaw)
+            return;
+
+        // set attributes values
+        for (const key in this.__scriptType.attributes.index) {
+            if (this.__attributesRaw && this.__attributesRaw.hasOwnProperty(key)) {
+                this[key] = this.__attributesRaw[key];
+            } else if (!this.__attributes.hasOwnProperty(key)) {
+                if (this.__scriptType.attributes.index[key].hasOwnProperty('default')) {
+                    this[key] = this.__scriptType.attributes.index[key].default;
+                } else {
+                    this[key] = null;
+                }
+            }
+        }
+
+        this.__attributesRaw = null;
+    }
+
+    /**
+     * Shorthand function to extend Script Type prototype with list of methods.
+     *
+     * @param {object} methods - Object with methods, where key - is name of method, and value - is function.
+     * @example
+     * var PlayerController = pc.createScript('playerController');
+     *
+     * PlayerController.extend({
+     *     initialize: function () {
+     *         // called once on initialize
+     *     },
+     *     update: function (dt) {
+     *         // called each tick
+     *     }
+     * });
+     */
+    static extend(methods) {
+        for (const key in methods) {
+            if (!methods.hasOwnProperty(key))
+                continue;
+
+            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 a136619f1..b25939d38 100644
--- a/src/framework/script/script.js
+++ b/src/framework/script/script.js
@@ -1,404 +1,156 @@
 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';
+import { script } from '../script.js';
+import { AppBase } from '../app-base.js';
 
-const funcNameRegex = new RegExp('^\\s*function(?:\\s|\\s*\\/\\*.*\\*\\/\\s*)+([^\\(\\s\\/]*)\\s*');
+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;
+}
 
 /**
- * The Script class is a base class that you must extend to receive
- * {@link https://developer.playcanvas.com/user-manual/scripting/anatomy/ various lifecycle updates}
- * from the engine.
- *
- * You can create a Script using either {@link createScript} or by extending the class directly.
- *
- * ```javascript
- * import { Script } from 'playcanvas';
- * class Rotator extends Script {
- *     update() {
- *         this.entity.rotate(0, 0.1, 0);
- *     }
- * }
- * ```
- * The following methods are called if they exist on the Script instance:
- *
- * - `initialize`
- * - `postInitialize`
- * - `update`
- * - `postUpdate`
- * - `swap`
+ * 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.
  *
- * `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.
+ * @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');
  *
- * `update` and `postUpdate` - are called (if defined) for enabled (running state) scripts on each
- * tick.
+ * // define 'speed' attribute that is available in Editor UI
+ * Turning.attributes.add('speed', {
+ *     type: 'number',
+ *     default: 180,
+ *     placeholder: 'deg/s'
+ * });
  *
- * `swap` - is called when a Script that already exists in the registry gets redefined. If the
- * new Script has a `swap` method in its prototype, then it will be executed to perform hot-
- * reload at runtime.
- *
- * @see {@link https://developer.playcanvas.com/user-manual/scripting/anatomy/} for more information.
+ * // runs every tick
+ * Turning.prototype.update = function (dt) {
+ *     this.entity.rotate(0, this.speed * dt, 0);
+ * };
  * @category Script
  */
-class Script 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;
-
-    /** @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 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.initScriptType(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.__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);
-        }
+function createScript(name, app) {
+    if (script.legacy) {
+        Debug.error('This project is using the legacy script system. You cannot call pc.createScript().');
+        return null;
     }
 
-    get enabled() {
-        return this._enabled && !this._destroyed && this.entity.script.enabled && this.entity.enabled;
-    }
+    if (reservedScriptNames.has(name))
+        throw new Error(`Script name '${name}' is reserved, please rename the script`);
 
-    /**
-     * @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`);
+    const scriptType = function (args) {
+        EventHandler.prototype.initEventHandler.call(this);
+        ScriptType.prototype.initScriptType.call(this, args);
+    };
 
-        this.app = args.app;
-        this.entity = args.entity;
+    scriptType.prototype = Object.create(ScriptType.prototype);
+    scriptType.prototype.constructor = scriptType;
 
-        this._enabled = typeof args.enabled === 'boolean' ? args.enabled : true;
-        this._enabledOld = this.enabled;
+    scriptType.extend = ScriptType.extend;
+    scriptType.attributes = new ScriptAttributes(scriptType);
 
-        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 {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}.
-     *
-     * @type {ScriptAttributes}
-     * @example
-     * var PlayerController = pc.createScript('playerController');
-     *
-     * PlayerController.attributes.add('speed', {
-     *     type: 'number',
-     *     title: 'Speed',
-     *     placeholder: 'km/h',
-     *     default: 22.2
-     * });
-     */
-    static get attributes() {
-        if (!this.hasOwnProperty('__attributes')) this.__attributes = new ScriptAttributes(this);
-        return this.__attributes;
-    }
-
-    /**
-     * @param {boolean} [force] - Set to true to force initialization of the attributes.
-     * @private
-     */
-    __initializeAttributes(force) {
-        if (!force && !this.__attributesRaw)
-            return;
+    registerScript(scriptType, name, app);
+    return scriptType;
+}
 
-        // set attributes values
-        for (const key in this.__scriptType.attributes.index) {
-            if (this.__attributesRaw && this.__attributesRaw.hasOwnProperty(key)) {
-                this[key] = this.__attributesRaw[key];
-            } else if (!this.__attributes.hasOwnProperty(key)) {
-                if (this.__scriptType.attributes.index[key].hasOwnProperty('default')) {
-                    this[key] = this.__scriptType.attributes.index[key].default;
-                } else {
-                    this[key] = null;
-                }
-            }
-        }
+// Editor uses this - migrate to ScriptAttributes.reservedNames and delete this
+const reservedAttributes = {};
+ScriptAttributes.reservedNames.forEach((value, value2, set) => {
+    reservedAttributes[value] = 1;
+});
+createScript.reservedAttributes = reservedAttributes;
 
-        this.__attributesRaw = null;
+/* 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 (script.legacy) {
+        Debug.error('This project is using the legacy script system. You cannot call pc.registerScript().');
+        return;
     }
 
-    /**
-     * Shorthand function to extend Script Type prototype with list of methods.
-     *
-     * @param {object} methods - Object with methods, where key - is name of method, and value - is function.
-     * @example
-     * var PlayerController = pc.createScript('playerController');
-     *
-     * PlayerController.extend({
-     *     initialize: function () {
-     *         // called once on initialize
-     *     },
-     *     update: function (dt) {
-     *         // called each tick
-     *     }
-     * });
-     */
-    static extend(methods) {
-        for (const key in methods) {
-            if (!methods.hasOwnProperty(key))
-                continue;
+    if (typeof script !== 'function')
+        throw new Error(`script class: '${script}' must be a constructor function (i.e. class).`);
 
-            this.prototype[key] = methods[key];
-        }
-    }
+    if (!(script.prototype instanceof ScriptType))
+        throw new Error(`script class: '${ScriptType.__getScriptName(script)}' does not extend pc.ScriptType.`);
 
-    /**
-     * @function
-     * @name Script#[initialize]
-     * @description Called when script is about to run for the first time.
-     */
+    name = name || script.__name || ScriptType.__getScriptName(script);
 
-    /**
-     * @function
-     * @name Script#[postInitialize]
-     * @description Called after all initialize methods are executed in the same tick or enabling chain of actions.
-     */
+    if (reservedScriptNames.has(name))
+        throw new Error(`script name: '${name}' is reserved, please change script name`);
 
-    /**
-     * @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.
-     */
+    script.__name = name;
 
-    /**
-     * @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.
-     */
+    // add to scripts registry
+    const registry = app ? app.scripts : AppBase.getApplication().scripts;
+    registry.add(script);
 
-    /**
-     * @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 in its prototype,
-     * then it will be executed to perform hot-reload at runtime.
-     * @param {Script} old - Old instance of the script to copy data to the new instance.
-     */
+    ScriptTypes.push(script, script.legacy);
 }
+/* eslint-enable jsdoc/check-examples */
 
-export { Script };
+export { createScript, registerScript, getReservedScriptNames };
diff --git a/src/index.js b/src/index.js
index 10c89ac5a..ee4274508 100644
--- a/src/index.js
+++ b/src/index.js
@@ -360,10 +360,10 @@ 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-create.js';
+export { createScript, registerScript, getReservedScriptNames } from './framework/script/script.js';
 export { ScriptAttributes } from './framework/script/script-attributes.js';
 export { ScriptRegistry } from './framework/script/script-registry.js';
-export { Script } from './framework/script/script.js';
+export { ScriptType } from './framework/script/script-type.js';
 
 // FRAMEWORK / LOCALIZATION
 export { I18n } from './framework/i18n/i18n.js';
diff --git a/src/platform/graphics/constants.js b/src/platform/graphics/constants.js
index 9709b973f..6199cdb15 100644
--- a/src/platform/graphics/constants.js
+++ b/src/platform/graphics/constants.js
@@ -2054,13 +2054,13 @@ export const SHADERSTAGE_FRAGMENT = 2;
  */
 export const SHADERSTAGE_COMPUTE = 4;
 
-// indices of commonly used bind groups
-// sorted in a way that any trailing bind groups can be unused in any render pass
-export const BINDGROUP_MESH = 0;
-export const BINDGROUP_VIEW = 1;
+// indices of commonly used bind groups, sorted from the least commonly changing to avoid internal rebinding
+export const BINDGROUP_VIEW = 0;        // view bind group, textures, samplers and uniforms
+export const BINDGROUP_MESH = 1;        // mesh bind group - textures and samplers
+export const BINDGROUP_MESH_UB = 2;     // mesh bind group - a single uniform buffer
 
 // names of bind groups
-export const bindGroupNames = ['mesh', 'view'];
+export const bindGroupNames = ['view', 'mesh', 'mesh_ub'];
 
 // name of the default uniform buffer slot in a bind group
 export const UNIFORM_BUFFER_DEFAULT_SLOT_NAME = 'default';
diff --git a/src/platform/graphics/graphics-device.js b/src/platform/graphics/graphics-device.js
index bdfd928c1..ee57634c2 100644
--- a/src/platform/graphics/graphics-device.js
+++ b/src/platform/graphics/graphics-device.js
@@ -264,6 +264,15 @@ class GraphicsDevice extends EventHandler {
       */
     textureHalfFloatRenderable;
 
+    /**
+     * True if small-float textures with format {@link PIXELFORMAT_111110F} can be used as a frame
+     * buffer. This is always true on WebGL2, but optional on WebGPU device.
+     *
+     * @type {boolean}
+     * @readonly
+     */
+    textureRG11B10Renderable = false;
+
      /**
       * True if filtering can be applied when sampling float textures.
       *
@@ -855,6 +864,12 @@ class GraphicsDevice extends EventHandler {
     /**
      * Get a renderable HDR pixel format supported by the graphics device.
      *
+     * Note:
+     * - When the `filterable` parameter is set to false, this function returns one of the supported
+     * formats on the majority of devices apart from some very old iOS and Android devices (99%).
+     * - When the `filterable` parameter is set to true, the function returns a format on a
+     * considerably lower number of devices (70%).
+     *
      * @param {number[]} [formats] - An array of pixel formats to check for support. Can contain:
      *
      * - {@link PIXELFORMAT_111110F}
diff --git a/src/platform/graphics/shader-processor.js b/src/platform/graphics/shader-processor.js
index 9319e7d19..205118a53 100644
--- a/src/platform/graphics/shader-processor.js
+++ b/src/platform/graphics/shader-processor.js
@@ -2,13 +2,13 @@ import { Debug } from '../../core/debug.js';
 import {
     BINDGROUP_MESH, uniformTypeToName, semanticToLocation,
     SHADERSTAGE_VERTEX, SHADERSTAGE_FRAGMENT,
-    UNIFORM_BUFFER_DEFAULT_SLOT_NAME,
     SAMPLETYPE_FLOAT, SAMPLETYPE_DEPTH, SAMPLETYPE_UNFILTERABLE_FLOAT,
     TEXTUREDIMENSION_2D, TEXTUREDIMENSION_2D_ARRAY, TEXTUREDIMENSION_CUBE, TEXTUREDIMENSION_3D,
-    TYPE_FLOAT32, TYPE_INT8, TYPE_INT16, TYPE_INT32, TYPE_FLOAT16, SAMPLETYPE_INT, SAMPLETYPE_UINT
+    TYPE_FLOAT32, TYPE_INT8, TYPE_INT16, TYPE_INT32, TYPE_FLOAT16, SAMPLETYPE_INT, SAMPLETYPE_UINT,
+    BINDGROUP_MESH_UB
 } from './constants.js';
 import { UniformFormat, UniformBufferFormat } from './uniform-buffer-format.js';
-import { BindGroupFormat, BindUniformBufferFormat, BindTextureFormat } from './bind-group-format.js';
+import { BindGroupFormat, BindTextureFormat } from './bind-group-format.js';
 
 // accepted keywords
 // TODO: 'out' keyword is not in the list, as handling it is more complicated due
@@ -269,14 +269,7 @@ class ShaderProcessor {
         });
         const meshUniformBufferFormat = meshUniforms.length ? new UniformBufferFormat(device, meshUniforms) : null;
 
-        // build mesh bind group format - start with uniform buffer
-        const uniformBufferFormats = [];
-        if (meshUniformBufferFormat) {
-            // TODO: we could optimize visibility to only stages that use any of the data
-            uniformBufferFormats.push(new BindUniformBufferFormat(UNIFORM_BUFFER_DEFAULT_SLOT_NAME, SHADERSTAGE_VERTEX | SHADERSTAGE_FRAGMENT));
-        }
-
-        // add textures uniforms
+        // build mesh bind group format - this contains the textures, but not the uniform buffer as that is a separate binding
         const textureFormats = [];
         uniformLinesSamplers.forEach((uniform) => {
             // unmatched texture uniforms go to mesh block
@@ -307,7 +300,7 @@ class ShaderProcessor {
             // validate types in else
 
         });
-        const meshBindGroupFormat = new BindGroupFormat(device, [...uniformBufferFormats, ...textureFormats]);
+        const meshBindGroupFormat = new BindGroupFormat(device, textureFormats);
 
         // generate code for uniform buffers
         let code = '';
@@ -319,7 +312,7 @@ class ShaderProcessor {
 
         // and also for generated mesh format, which is at the slot 0 of the bind group
         if (meshUniformBufferFormat) {
-            code += meshUniformBufferFormat.getShaderDeclaration(BINDGROUP_MESH, 0);
+            code += meshUniformBufferFormat.getShaderDeclaration(BINDGROUP_MESH_UB, 0);
         }
 
         // generate code for textures
diff --git a/src/platform/graphics/texture.js b/src/platform/graphics/texture.js
index 96bca13b7..ee09bcba1 100644
--- a/src/platform/graphics/texture.js
+++ b/src/platform/graphics/texture.js
@@ -25,6 +25,27 @@ let id = 0;
  * A texture is a container for texel data that can be utilized in a fragment shader. Typically,
  * the texel data represents an image that is mapped over geometry.
  *
+ * Note on **HDR texture format** support:
+ * 1. **As textures**:
+ *     - float (i.e. {@link PIXELFORMAT_RGBA32F}), half-float (i.e. {@link PIXELFORMAT_RGBA16F}) and
+ * small-float ({@link PIXELFORMAT_111110F}) formats are always supported on both WebGL2 and WebGPU
+ * with point sampling.
+ *     - half-float and small-float formats are always supported on WebGL2 and WebGPU with linear
+ * sampling.
+ *     - float formats are supported on WebGL2 and WebGPU with linear sampling only if
+ * {@link GraphicsDevice#textureFloatFilterable} is true.
+ *
+ * 2. **As renderable textures** that can be used as color buffers in a {@link RenderTarget}:
+ *     - on WebGPU, rendering to float and half-float formats is always supported.
+ *     - on WebGPU, rendering to small-float format is supported only if
+ * {@link GraphicsDevice#textureRG11B10Renderable} is true.
+ *     - on WebGL2, rendering to these 3 formats formats is supported only if
+ * {@link GraphicsDevice#textureFloatRenderable} is true.
+ *     - on WebGL2, if {@link GraphicsDevice#textureFloatRenderable} is false, but
+ * {@link GraphicsDevice#textureHalfFloatRenderable} is true, rendering to half-float formats only
+ * is supported. This is the case of many mobile iOS devices.
+ *     - you can determine available renderable HDR format using
+ * {@link GraphicsDevice#getRenderableHdrFormat}.
  * @category Graphics
  */
 class Texture {
diff --git a/src/platform/graphics/webgpu/webgpu-clear-renderer.js b/src/platform/graphics/webgpu/webgpu-clear-renderer.js
index 176acb2ba..6cd1c7943 100644
--- a/src/platform/graphics/webgpu/webgpu-clear-renderer.js
+++ b/src/platform/graphics/webgpu/webgpu-clear-renderer.js
@@ -4,7 +4,8 @@ import { BlendState } from "../blend-state.js";
 import {
     CULLFACE_NONE,
     PRIMITIVE_TRISTRIP, SHADERLANGUAGE_WGSL,
-    UNIFORMTYPE_FLOAT, UNIFORMTYPE_VEC4, BINDGROUP_MESH, CLEARFLAG_COLOR, CLEARFLAG_DEPTH, CLEARFLAG_STENCIL
+    UNIFORMTYPE_FLOAT, UNIFORMTYPE_VEC4, BINDGROUP_MESH, CLEARFLAG_COLOR, CLEARFLAG_DEPTH, CLEARFLAG_STENCIL,
+    BINDGROUP_MESH_UB
 } from "../constants.js";
 import { Shader } from "../shader.js";
 import { DynamicBindGroup } from "../bind-group.js";
@@ -39,7 +40,7 @@ class WebgpuClearRenderer {
                 depth: f32
             }
 
-            @group(0) @binding(0) var<uniform> ubMesh : ub_mesh;
+            @group(2) @binding(0) var<uniform> ubMesh : ub_mesh;
 
             var<private> pos : array<vec2f, 4> = array<vec2f, 4>(
                 vec2(-1.0, 1.0), vec2(1.0, 1.0),
@@ -98,10 +99,13 @@ class WebgpuClearRenderer {
 
             DebugGraphics.pushGpuMarker(device, 'CLEAR-RENDERER');
 
-            // dynamic bind group for this UB
+            // dynamic bind group for the UB
             const { uniformBuffer, dynamicBindGroup } = this;
             uniformBuffer.startUpdate(dynamicBindGroup);
-            device.setBindGroup(BINDGROUP_MESH, dynamicBindGroup.bindGroup, dynamicBindGroup.offsets);
+            device.setBindGroup(BINDGROUP_MESH_UB, dynamicBindGroup.bindGroup, dynamicBindGroup.offsets);
+
+            // not using mesh bind group
+            device.setBindGroup(BINDGROUP_MESH, device.emptyBindGroup);
 
             // setup clear color
             if ((flags & CLEARFLAG_COLOR) && (renderTarget.colorBuffer || renderTarget.impl.assignedColorTexture)) {
@@ -136,7 +140,6 @@ class WebgpuClearRenderer {
 
             // render 4 vertices without vertex buffer
             device.setShader(this.shader);
-
             device.draw(primitive);
 
             DebugGraphics.popGpuMarker(device);
diff --git a/src/platform/graphics/webgpu/webgpu-graphics-device.js b/src/platform/graphics/webgpu/webgpu-graphics-device.js
index 4214f3b6e..71440f7b7 100644
--- a/src/platform/graphics/webgpu/webgpu-graphics-device.js
+++ b/src/platform/graphics/webgpu/webgpu-graphics-device.js
@@ -29,6 +29,8 @@ import { WebgpuGpuProfiler } from './webgpu-gpu-profiler.js';
 import { WebgpuResolver } from './webgpu-resolver.js';
 import { WebgpuCompute } from './webgpu-compute.js';
 import { WebgpuBuffer } from './webgpu-buffer.js';
+import { BindGroupFormat } from '../bind-group-format.js';
+import { BindGroup } from '../bind-group.js';
 
 const _uniqueLocations = new Map();
 
@@ -72,6 +74,14 @@ class WebgpuGraphicsDevice extends GraphicsDevice {
      */
     bindGroupFormats = [];
 
+    /**
+     * An empty bind group, used when the draw call is using a typical bind group layout based on
+     * BINDGROUP_*** constants but some bind groups are not needed, for example clear renderer.
+     *
+     * @type {BindGroup}
+     */
+    emptyBindGroup;
+
     /**
      * Current command buffer encoder.
      *
@@ -310,6 +320,10 @@ class WebgpuGraphicsDevice extends GraphicsDevice {
 
         // init dynamic buffer using 1MB allocation
         this.dynamicBuffers = new WebgpuDynamicBuffers(this, 1024 * 1024, this.limits.minUniformBufferOffsetAlignment);
+
+        // empty bind group
+        this.emptyBindGroup = new BindGroup(this, new BindGroupFormat(this, []));
+        this.emptyBindGroup.update();
     }
 
     createBackbuffer() {
diff --git a/src/platform/graphics/webgpu/webgpu-render-pipeline.js b/src/platform/graphics/webgpu/webgpu-render-pipeline.js
index 8ff449f8a..80945f1a3 100644
--- a/src/platform/graphics/webgpu/webgpu-render-pipeline.js
+++ b/src/platform/graphics/webgpu/webgpu-render-pipeline.js
@@ -7,6 +7,7 @@ import { WebgpuVertexBufferLayout } from "./webgpu-vertex-buffer-layout.js";
 import { WebgpuDebug } from "./webgpu-debug.js";
 import { WebgpuPipeline } from "./webgpu-pipeline.js";
 import { DebugGraphics } from "../debug-graphics.js";
+import { bindGroupNames } from "../constants.js";
 
 let _pipelineId = 0;
 
@@ -120,6 +121,12 @@ class WebgpuRenderPipeline extends WebgpuPipeline {
 
         Debug.assert(bindGroupFormats.length <= 3);
 
+        // all bind groups must be set as the WebGPU layout cannot have skipped indices. Not having a bind
+        // group would assign incorrect slots to the following bind groups, causing a validation errors.
+        Debug.assert(bindGroupFormats[0], `BindGroup with index 0 [${bindGroupNames[0]}] is not set.`);
+        Debug.assert(bindGroupFormats[1], `BindGroup with index 1 [${bindGroupNames[1]}] is not set.`);
+        Debug.assert(bindGroupFormats[2], `BindGroup with index 2 [${bindGroupNames[2]}] is not set.`);
+
         // render pipeline unique hash
         const lookupHashes = this.lookupHashes;
         lookupHashes[0] = primitive.type;
diff --git a/src/scene/graphics/quad-render.js b/src/scene/graphics/quad-render.js
index b8f363f64..ab7693fd8 100644
--- a/src/scene/graphics/quad-render.js
+++ b/src/scene/graphics/quad-render.js
@@ -1,7 +1,7 @@
 import { Debug, DebugHelper } from "../../core/debug.js";
 import { Vec4 } from "../../core/math/vec4.js";
-import { BindGroup } from "../../platform/graphics/bind-group.js";
-import { BINDGROUP_MESH, PRIMITIVE_TRISTRIP } from "../../platform/graphics/constants.js";
+import { BindGroup, DynamicBindGroup } from "../../platform/graphics/bind-group.js";
+import { BINDGROUP_MESH, BINDGROUP_MESH_UB, BINDGROUP_VIEW, PRIMITIVE_TRISTRIP } from "../../platform/graphics/constants.js";
 import { DebugGraphics } from "../../platform/graphics/debug-graphics.js";
 import { ShaderProcessorOptions } from "../../platform/graphics/shader-processor-options.js";
 import { UniformBuffer } from "../../platform/graphics/uniform-buffer.js";
@@ -16,6 +16,7 @@ const _quadPrimitive = {
 
 const _tempViewport = new Vec4();
 const _tempScissor = new Vec4();
+const _dynamicBindGroup = new DynamicBindGroup();
 
 /**
  * An object that renders a quad using a {@link Shader}.
@@ -70,7 +71,7 @@ class QuadRender {
             // bind group
             const bindGroupFormat = this.shader.meshBindGroupFormat;
             Debug.assert(bindGroupFormat);
-            this.bindGroup = new BindGroup(device, bindGroupFormat, this.uniformBuffer);
+            this.bindGroup = new BindGroup(device, bindGroupFormat);
             DebugHelper.setName(this.bindGroup, `QuadRender-MeshBindGroup_${this.bindGroup.id}`);
         }
     }
@@ -120,10 +121,22 @@ class QuadRender {
 
         if (device.supportsUniformBuffers) {
 
+            // not using view bind group
+            device.setBindGroup(BINDGROUP_VIEW, device.emptyBindGroup);
+
+            // mesh bind group
             const bindGroup = this.bindGroup;
-            bindGroup.defaultUniformBuffer?.update();
             bindGroup.update();
             device.setBindGroup(BINDGROUP_MESH, bindGroup);
+
+            // dynamic uniform buffer bind group
+            const uniformBuffer = this.uniformBuffer;
+            if (uniformBuffer) {
+                uniformBuffer.update(_dynamicBindGroup);
+                device.setBindGroup(BINDGROUP_MESH_UB, _dynamicBindGroup.bindGroup, _dynamicBindGroup.offsets);
+            } else {
+                device.setBindGroup(BINDGROUP_MESH_UB, device.emptyBindGroup);
+            }
         }
 
         device.draw(_quadPrimitive);
diff --git a/src/scene/immediate/immediate-batch.js b/src/scene/immediate/immediate-batch.js
index e0bed82aa..26113dbde 100644
--- a/src/scene/immediate/immediate-batch.js
+++ b/src/scene/immediate/immediate-batch.js
@@ -91,14 +91,16 @@ class ImmediateBatch {
                 this.meshInstance = new MeshInstance(this.mesh, this.material, identityGraphNode);
             }
 
-            // clear lines when after they were rendered as their lifetime is one frame
-            this.positions.length = 0;
-            this.colors.length = 0;
-
             // inject mesh instance into visible list to be rendered
             visibleList.push(this.meshInstance);
         }
     }
+
+    clear() {
+        // clear lines after they are rendered as their lifetime is one frame
+        this.positions.length = 0;
+        this.colors.length = 0;
+    }
 }
 
 export { ImmediateBatch };
diff --git a/src/scene/immediate/immediate-batches.js b/src/scene/immediate/immediate-batches.js
index e564c8218..cd6e3716a 100644
--- a/src/scene/immediate/immediate-batches.js
+++ b/src/scene/immediate/immediate-batches.js
@@ -23,6 +23,10 @@ class ImmediateBatches {
             batch.onPreRender(visibleList, transparent);
         });
     }
+
+    clear() {
+        this.map.forEach(batch => batch.clear());
+    }
 }
 
 export { ImmediateBatches };
diff --git a/src/scene/immediate/immediate.js b/src/scene/immediate/immediate.js
index d2d9ee559..5203ffc8c 100644
--- a/src/scene/immediate/immediate.js
+++ b/src/scene/immediate/immediate.js
@@ -275,6 +275,7 @@ class Immediate {
     onPostRender() {
 
         // clean up line batches
+        this.allBatches.forEach(batch => batch.clear());
         this.allBatches.clear();
 
         // all batches need updating next frame
diff --git a/src/scene/mesh-instance.js b/src/scene/mesh-instance.js
index 24df3a06a..1162c9faa 100644
--- a/src/scene/mesh-instance.js
+++ b/src/scene/mesh-instance.js
@@ -59,12 +59,19 @@ class ShaderInstance {
     shader;
 
     /**
-     * A bind group storing mesh uniforms for the shader.
+     * A bind group storing mesh textures / samplers for the shader. but not the uniform buffer.
      *
      * @type {BindGroup|null}
      */
     bindGroup = null;
 
+    /**
+     * A uniform buffer storing mesh uniforms for the shader.
+     *
+     * @type {UniformBuffer|null}
+     */
+    uniformBuffer = null;
+
     /**
      * Returns the mesh bind group for the shader.
      *
@@ -79,28 +86,43 @@ class ShaderInstance {
             const shader = this.shader;
             Debug.assert(shader);
 
-            // mesh uniform buffer
-            const ubFormat = shader.meshUniformBufferFormat;
-            Debug.assert(ubFormat);
-            const uniformBuffer = new UniformBuffer(device, ubFormat, false);
-
-            // mesh bind group
             const bindGroupFormat = shader.meshBindGroupFormat;
             Debug.assert(bindGroupFormat);
-            this.bindGroup = new BindGroup(device, bindGroupFormat, uniformBuffer);
+            this.bindGroup = new BindGroup(device, bindGroupFormat);
             DebugHelper.setName(this.bindGroup, `MeshBindGroup_${this.bindGroup.id}`);
         }
 
         return this.bindGroup;
     }
 
-    destroy() {
-        const group = this.bindGroup;
-        if (group) {
-            group.defaultUniformBuffer?.destroy();
-            group.destroy();
-            this.bindGroup = null;
+    /**
+     * Returns the uniform buffer for the shader.
+     *
+     * @param {import('../platform/graphics/graphics-device.js').GraphicsDevice} device - The
+     * graphics device.
+     * @returns {UniformBuffer} - The uniform buffer.
+     */
+    getUniformBuffer(device) {
+
+        // create uniform buffer
+        if (!this.uniformBuffer) {
+            const shader = this.shader;
+            Debug.assert(shader);
+
+            const ubFormat = shader.meshUniformBufferFormat;
+            Debug.assert(ubFormat);
+            this.uniformBuffer = new UniformBuffer(device, ubFormat, false);
         }
+
+        return this.uniformBuffer;
+    }
+
+    destroy() {
+        this.bindGroup?.destroy();
+        this.bindGroup = null;
+
+        this.uniformBuffer?.destroy();
+        this.uniformBuffer = null;
     }
 }
 
diff --git a/src/scene/renderer/forward-renderer.js b/src/scene/renderer/forward-renderer.js
index e41d455cf..d386fdad5 100644
--- a/src/scene/renderer/forward-renderer.js
+++ b/src/scene/renderer/forward-renderer.js
@@ -685,14 +685,6 @@ class ForwardRenderer extends Renderer {
 
         this.setupViewport(camera, renderTarget);
 
-        // clearing
-        const clearColor = options.clearColor ?? false;
-        const clearDepth = options.clearDepth ?? false;
-        const clearStencil = options.clearStencil ?? false;
-        if (clearColor || clearDepth || clearStencil) {
-            this.clear(camera, clearColor, clearDepth, clearStencil);
-        }
-
         let visible, splitLights;
         if (layer) {
             // #if _PROFILER
@@ -748,6 +740,14 @@ class ForwardRenderer extends Renderer {
             this.setupViewUniformBuffers(viewBindGroups, this.viewUniformFormat, this.viewBindGroupFormat, viewCount);
         }
 
+        // clearing - do it after the view bind groups are set up, to avoid overriding those
+        const clearColor = options.clearColor ?? false;
+        const clearDepth = options.clearDepth ?? false;
+        const clearStencil = options.clearStencil ?? false;
+        if (clearColor || clearDepth || clearStencil) {
+            this.clear(camera, clearColor, clearDepth, clearStencil);
+        }
+
         // enable flip faces if either the camera has _flipFaces enabled or the render target has flipY enabled
         const flipFaces = !!(camera._flipFaces ^ renderTarget?.flipY);
 
diff --git a/src/scene/renderer/renderer.js b/src/scene/renderer/renderer.js
index eabdcc8fe..7f82522a9 100644
--- a/src/scene/renderer/renderer.js
+++ b/src/scene/renderer/renderer.js
@@ -24,11 +24,12 @@ import {
     SHADERSTAGE_VERTEX, SHADERSTAGE_FRAGMENT,
     SEMANTIC_ATTR,
     CULLFACE_BACK, CULLFACE_FRONT, CULLFACE_NONE,
-    TEXTUREDIMENSION_2D, SAMPLETYPE_UNFILTERABLE_FLOAT, SAMPLETYPE_FLOAT, SAMPLETYPE_DEPTH
+    TEXTUREDIMENSION_2D, SAMPLETYPE_UNFILTERABLE_FLOAT, SAMPLETYPE_FLOAT, SAMPLETYPE_DEPTH,
+    BINDGROUP_MESH_UB
 } from '../../platform/graphics/constants.js';
 import { DebugGraphics } from '../../platform/graphics/debug-graphics.js';
 import { UniformBuffer } from '../../platform/graphics/uniform-buffer.js';
-import { BindGroup } from '../../platform/graphics/bind-group.js';
+import { BindGroup, DynamicBindGroup } from '../../platform/graphics/bind-group.js';
 import { UniformFormat, UniformBufferFormat } from '../../platform/graphics/uniform-buffer-format.js';
 import { BindGroupFormat, BindUniformBufferFormat, BindTextureFormat } from '../../platform/graphics/bind-group-format.js';
 
@@ -50,6 +51,7 @@ const tempSphere = new BoundingSphere();
 const _flipYMat = new Mat4().setScale(1, -1, 1);
 const _tempLightSet = new Set();
 const _tempLayerSet = new Set();
+const _dynamicBindGroup = new DynamicBindGroup();
 
 // Converts a projection matrix in OpenGL style (depth range of -1..1) to a DirectX style (depth range of 0..1).
 const _fixProjRangeMat = new Mat4().set([
@@ -866,10 +868,12 @@ class Renderer {
 
             // update mesh bind group / uniform buffer
             const meshBindGroup = shaderInstance.getBindGroup(device);
-
-            meshBindGroup.defaultUniformBuffer.update();
             meshBindGroup.update();
             device.setBindGroup(BINDGROUP_MESH, meshBindGroup);
+
+            const meshUniformBuffer = shaderInstance.getUniformBuffer(device);
+            meshUniformBuffer.update(_dynamicBindGroup);
+            device.setBindGroup(BINDGROUP_MESH_UB, _dynamicBindGroup.bindGroup, _dynamicBindGroup.offsets);
         }
     }
 
diff --git a/utils/plugins/rollup-types-fixup.mjs b/utils/plugins/rollup-types-fixup.mjs
index 37d9d181a..8bc60ebd7 100644
--- a/utils/plugins/rollup-types-fixup.mjs
+++ b/utils/plugins/rollup-types-fixup.mjs
@@ -209,7 +209,7 @@ import { Texture } from '../../platform/graphics/texture.js';
 `
     }
 }, {
-    path: `${TYPES_PATH}/framework/script/script.d.ts`,
+    path: `${TYPES_PATH}/framework/script/script-type.d.ts`,
     replacement: {
         from: 'get enabled(): boolean;',
         to: `get enabled(): boolean;
@@ -232,12 +232,12 @@ import { Texture } from '../../platform/graphics/texture.js';
      */
     postUpdate?(dt: number): void;
     /**
-     * Called when a Script that already exists in the registry gets redefined. If the new
-     * Script has a \`swap\` method in its prototype, then it will be executed to perform
+     * 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 old - Old instance of the script to copy data to the new instance.
+     * @param old - Old instance of the scriptType to copy data to the new instance.
      */
-    swap?(old: Script): void;
+    swap?(old: ScriptType): void;
 `
     }
 }];


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