Repository: playcanvas/engine
Pull request #6714 (2024-06-18 12:24:53)

[BREAKING] Colors of lights, fog and materials are always supplied in sRGB space, for both LDR and HDR
Fixes https://github.com/playcanvas/engine/issues/6709

Marking this as BREAKING, as due to fixes for HDR rendering, this might look different. Also see the API change listed below.

Follow up to https://github.com/playcanvas/engine/pull/6702

Unified the final output between LDR (default) and HDR rendering. Before this PR, the HDR rendering would be typically brighter. The main reason is that the HDR mode expected colors to be set in linear space, unlike in SDR where those were expected in sRGB. Now all inputs are unified, always in sRGB, so that the scene set up / GLBs looks the same in LDR and HDR.

- added constant TONEMAP_NONE. Before, internally, we'd just use hardcoded -1, but this needs to be available as public API for HDR
- light color as uniform is always in linear space. Before, when HDR was used, the user was expected to assign linear colors. Now we always expect sRGB colors and convert them to linear. This applies to ambient light, but also directional, omni and spot, including clustered lights.
- material color properties were expected be in the gamma space or linear space depending on scene.gammaCorrection. Now they're always expected to be in gamma space, and we always convert them to linear uniforms
- similarly to materials, the fog color is now always expected in sRGB, including in HDR mode, and we convert it to linear uniforms
- UI now works in the linear space, which means the source (emissive) texture is converted from gamma to linear, and a linear to gamma is applied at the end of the shader. Note that this will be in most cases optimized out when this is done: https://github.com/playcanvas/engine/issues/3715

## New API
- `Color.linear(source)` - converts sRGB to linear
- `Color.gamma(source)` - converts linear to sRGB
- `Color.mulScalar(scalar)` - multiplies with scalar, applied to RGB only
- `TONEMAP_NONE` - new constants, used in HDR rendering

## Changed API [BREAKING]
`StandardMaterial.useGammaTonemap` has been deprecated, and `StandardMaterial.useTonemap` has been added instead. We no longer expose the gamma control on the standard material.

mvaligursky: There is more work to do on the UI side, as that needs gamma changes now to fit as well.
MAG-AdrianMeredith: How do you access RenderingParams?  its not initialised on the camera by default and doesn't seem to be exported
mvaligursky: > How do you access RenderingParams? its not initialised on the camera by default and doesn't seem to be exported

Nice, adding the export shortly, sorry.

Patch:
diff --git a/examples/assets/models/playbot/26020273/Playbot_head.json b/examples/assets/models/playbot/26020273/Playbot_head.json
index a2dcad569..0ddd66245 100644
--- a/examples/assets/models/playbot/26020273/Playbot_head.json
+++ b/examples/assets/models/playbot/26020273/Playbot_head.json
@@ -1,1 +1,1 @@
-{"shader":"blinn","ambient":[0.588,0.588,0.588],"diffuse":[0.588,0.588,0.588],"diffuseMap":"../26020286/head_clean.png","diffuseMapOffset":[0,0],"diffuseMapTiling":[1,1],"specular":[0.9,0.9,0.9],"shininess":90.9091,"emissive":[0,0,0],"emissiveMap":"../26020277/head_E.png","emissiveMapOffset":[0,0],"emissiveMapTiling":[1,1],"normalMap":"../26020276/head_N_clean.png","normalMapOffset":[0,0],"normalMapTiling":[1,1],"bumpMapFactor":0.3,"opacity":1,"sphereMap":"../26020278/env_01.png","reflectivity":0.2,"aoMapChannel":"r","aoMapTiling":[1,1],"aoMapOffset":[0,0],"occludeSpecular":1,"diffuseMapChannel":"rgb","specularMapChannel":"rgb","specularMapTiling":[1,1],"specularMapOffset":[0,0],"specularAntialias":true,"metalnessMapChannel":"r","metalnessMapTiling":[1,1],"metalnessMapOffset":[0,0],"metalness":1,"conserveEnergy":true,"glossMapChannel":"r","glossMapTiling":[1,1],"glossMapOffset":[0,0],"emissiveMapChannel":"rgb","emissiveIntensity":1,"heightMapChannel":"r","heightMapTiling":[1,1],"heightMapOffset":[0,0],"heightMapFactor":1,"opacityMapChannel":"r","opacityMapTiling":[1,1],"opacityMapOffset":[0,0],"refractionIndex":0.6666666666666666,"cubeMapProjectionBox":{"center":[0,0,0],"halfExtents":[0.5,0.5,0.5]},"lightMapChannel":"rgb","lightMapUv":1,"lightMapTiling":[1,1],"lightMapOffset":[0,0],"depthTest":true,"depthWrite":true,"cull":1,"blendType":3,"shadowSampleType":1,"useFog":true,"useLighting":true,"useSkybox":true,"useGammaTonemap":true,"mapping_format":"path"}
\ No newline at end of file
+{"shader":"blinn","ambient":[0.588,0.588,0.588],"diffuse":[0.588,0.588,0.588],"diffuseMap":"../26020286/head_clean.png","diffuseMapOffset":[0,0],"diffuseMapTiling":[1,1],"specular":[0.9,0.9,0.9],"shininess":90.9091,"emissive":[0,0,0],"emissiveMap":"../26020277/head_E.png","emissiveMapOffset":[0,0],"emissiveMapTiling":[1,1],"normalMap":"../26020276/head_N_clean.png","normalMapOffset":[0,0],"normalMapTiling":[1,1],"bumpMapFactor":0.3,"opacity":1,"sphereMap":"../26020278/env_01.png","reflectivity":0.2,"aoMapChannel":"r","aoMapTiling":[1,1],"aoMapOffset":[0,0],"occludeSpecular":1,"diffuseMapChannel":"rgb","specularMapChannel":"rgb","specularMapTiling":[1,1],"specularMapOffset":[0,0],"specularAntialias":true,"metalnessMapChannel":"r","metalnessMapTiling":[1,1],"metalnessMapOffset":[0,0],"metalness":1,"conserveEnergy":true,"glossMapChannel":"r","glossMapTiling":[1,1],"glossMapOffset":[0,0],"emissiveMapChannel":"rgb","emissiveIntensity":1,"heightMapChannel":"r","heightMapTiling":[1,1],"heightMapOffset":[0,0],"heightMapFactor":1,"opacityMapChannel":"r","opacityMapTiling":[1,1],"opacityMapOffset":[0,0],"refractionIndex":0.6666666666666666,"cubeMapProjectionBox":{"center":[0,0,0],"halfExtents":[0.5,0.5,0.5]},"lightMapChannel":"rgb","lightMapUv":1,"lightMapTiling":[1,1],"lightMapOffset":[0,0],"depthTest":true,"depthWrite":true,"cull":1,"blendType":3,"shadowSampleType":1,"useFog":true,"useLighting":true,"useSkybox":true,"mapping_format":"path"}
\ No newline at end of file
diff --git a/examples/assets/models/playbot/26020274/Playbot_body.json b/examples/assets/models/playbot/26020274/Playbot_body.json
index b6de6c964..8182ada2b 100644
--- a/examples/assets/models/playbot/26020274/Playbot_body.json
+++ b/examples/assets/models/playbot/26020274/Playbot_body.json
@@ -1,1 +1,1 @@
-{"shader":"blinn","ambient":[0.588,0.588,0.588],"diffuse":[0.588,0.588,0.588],"diffuseMap":"../26020287/body_clean.png","diffuseMapOffset":[0,0],"diffuseMapTiling":[1,1],"specular":[0.9,0.9,0.9],"shininess":90.9091,"emissive":[0,0,0],"emissiveMap":"../26020288/body_E.png","emissiveMapOffset":[0,0],"emissiveMapTiling":[1,1],"normalMap":"../26020280/body_N_clean.png","normalMapOffset":[0,0],"normalMapTiling":[1,1],"bumpMapFactor":0.3,"opacity":1,"sphereMap":"../26020278/env_01.png","reflectivity":0.2,"aoMapChannel":"r","aoMapTiling":[1,1],"aoMapOffset":[0,0],"occludeSpecular":1,"diffuseMapChannel":"rgb","specularMapChannel":"rgb","specularMapTiling":[1,1],"specularMapOffset":[0,0],"specularAntialias":true,"metalnessMapChannel":"r","metalnessMapTiling":[1,1],"metalnessMapOffset":[0,0],"metalness":1,"conserveEnergy":true,"glossMapChannel":"r","glossMapTiling":[1,1],"glossMapOffset":[0,0],"emissiveMapChannel":"rgb","emissiveIntensity":1,"heightMapChannel":"r","heightMapTiling":[1,1],"heightMapOffset":[0,0],"heightMapFactor":1,"opacityMapChannel":"r","opacityMapTiling":[1,1],"opacityMapOffset":[0,0],"refractionIndex":0.6666666666666666,"cubeMapProjectionBox":{"center":[0,0,0],"halfExtents":[0.5,0.5,0.5]},"lightMapChannel":"rgb","lightMapUv":1,"lightMapTiling":[1,1],"lightMapOffset":[0,0],"depthTest":true,"depthWrite":true,"cull":1,"blendType":3,"shadowSampleType":1,"useFog":true,"useLighting":true,"useSkybox":true,"useGammaTonemap":true,"mapping_format":"path"}
\ No newline at end of file
+{"shader":"blinn","ambient":[0.588,0.588,0.588],"diffuse":[0.588,0.588,0.588],"diffuseMap":"../26020287/body_clean.png","diffuseMapOffset":[0,0],"diffuseMapTiling":[1,1],"specular":[0.9,0.9,0.9],"shininess":90.9091,"emissive":[0,0,0],"emissiveMap":"../26020288/body_E.png","emissiveMapOffset":[0,0],"emissiveMapTiling":[1,1],"normalMap":"../26020280/body_N_clean.png","normalMapOffset":[0,0],"normalMapTiling":[1,1],"bumpMapFactor":0.3,"opacity":1,"sphereMap":"../26020278/env_01.png","reflectivity":0.2,"aoMapChannel":"r","aoMapTiling":[1,1],"aoMapOffset":[0,0],"occludeSpecular":1,"diffuseMapChannel":"rgb","specularMapChannel":"rgb","specularMapTiling":[1,1],"specularMapOffset":[0,0],"specularAntialias":true,"metalnessMapChannel":"r","metalnessMapTiling":[1,1],"metalnessMapOffset":[0,0],"metalness":1,"conserveEnergy":true,"glossMapChannel":"r","glossMapTiling":[1,1],"glossMapOffset":[0,0],"emissiveMapChannel":"rgb","emissiveIntensity":1,"heightMapChannel":"r","heightMapTiling":[1,1],"heightMapOffset":[0,0],"heightMapFactor":1,"opacityMapChannel":"r","opacityMapTiling":[1,1],"opacityMapOffset":[0,0],"refractionIndex":0.6666666666666666,"cubeMapProjectionBox":{"center":[0,0,0],"halfExtents":[0.5,0.5,0.5]},"lightMapChannel":"rgb","lightMapUv":1,"lightMapTiling":[1,1],"lightMapOffset":[0,0],"depthTest":true,"depthWrite":true,"cull":1,"blendType":3,"shadowSampleType":1,"useFog":true,"useLighting":true,"useSkybox":true,"mapping_format":"path"}
\ No newline at end of file
diff --git a/examples/assets/models/playbot/26020283/Playbot_arm.json b/examples/assets/models/playbot/26020283/Playbot_arm.json
index a1d7586dc..71c5dbfb9 100644
--- a/examples/assets/models/playbot/26020283/Playbot_arm.json
+++ b/examples/assets/models/playbot/26020283/Playbot_arm.json
@@ -1,1 +1,1 @@
-{"shader":"blinn","ambient":[0.588,0.588,0.588],"diffuse":[0.588,0.588,0.588],"diffuseMap":"../26020279/arm_clean.png","diffuseMapOffset":[0,0],"diffuseMapTiling":[1,1],"specular":[0.9,0.9,0.9],"shininess":90.9091,"emissive":[0,0,0],"emissiveMap":"../26020281/arm_E.png","emissiveMapOffset":[0,0],"emissiveMapTiling":[1,1],"normalMap":"../26020289/arm_N_clean.png","normalMapOffset":[0,0],"normalMapTiling":[1,1],"bumpMapFactor":0.3,"opacity":1,"sphereMap":"../26020278/env_01.png","reflectivity":0.2,"aoMapChannel":"r","aoMapTiling":[1,1],"aoMapOffset":[0,0],"occludeSpecular":1,"diffuseMapChannel":"rgb","specularMapChannel":"rgb","specularMapTiling":[1,1],"specularMapOffset":[0,0],"specularAntialias":true,"metalnessMapChannel":"r","metalnessMapTiling":[1,1],"metalnessMapOffset":[0,0],"metalness":1,"conserveEnergy":true,"glossMapChannel":"r","glossMapTiling":[1,1],"glossMapOffset":[0,0],"emissiveMapChannel":"rgb","emissiveIntensity":1,"heightMapChannel":"r","heightMapTiling":[1,1],"heightMapOffset":[0,0],"heightMapFactor":1,"opacityMapChannel":"r","opacityMapTiling":[1,1],"opacityMapOffset":[0,0],"refractionIndex":0.6666666666666666,"cubeMapProjectionBox":{"center":[0,0,0],"halfExtents":[0.5,0.5,0.5]},"lightMapChannel":"rgb","lightMapUv":1,"lightMapTiling":[1,1],"lightMapOffset":[0,0],"depthTest":true,"depthWrite":true,"cull":1,"blendType":3,"shadowSampleType":1,"useFog":true,"useLighting":true,"useSkybox":true,"useGammaTonemap":true,"mapping_format":"path"}
\ No newline at end of file
+{"shader":"blinn","ambient":[0.588,0.588,0.588],"diffuse":[0.588,0.588,0.588],"diffuseMap":"../26020279/arm_clean.png","diffuseMapOffset":[0,0],"diffuseMapTiling":[1,1],"specular":[0.9,0.9,0.9],"shininess":90.9091,"emissive":[0,0,0],"emissiveMap":"../26020281/arm_E.png","emissiveMapOffset":[0,0],"emissiveMapTiling":[1,1],"normalMap":"../26020289/arm_N_clean.png","normalMapOffset":[0,0],"normalMapTiling":[1,1],"bumpMapFactor":0.3,"opacity":1,"sphereMap":"../26020278/env_01.png","reflectivity":0.2,"aoMapChannel":"r","aoMapTiling":[1,1],"aoMapOffset":[0,0],"occludeSpecular":1,"diffuseMapChannel":"rgb","specularMapChannel":"rgb","specularMapTiling":[1,1],"specularMapOffset":[0,0],"specularAntialias":true,"metalnessMapChannel":"r","metalnessMapTiling":[1,1],"metalnessMapOffset":[0,0],"metalness":1,"conserveEnergy":true,"glossMapChannel":"r","glossMapTiling":[1,1],"glossMapOffset":[0,0],"emissiveMapChannel":"rgb","emissiveIntensity":1,"heightMapChannel":"r","heightMapTiling":[1,1],"heightMapOffset":[0,0],"heightMapFactor":1,"opacityMapChannel":"r","opacityMapTiling":[1,1],"opacityMapOffset":[0,0],"refractionIndex":0.6666666666666666,"cubeMapProjectionBox":{"center":[0,0,0],"halfExtents":[0.5,0.5,0.5]},"lightMapChannel":"rgb","lightMapUv":1,"lightMapTiling":[1,1],"lightMapOffset":[0,0],"depthTest":true,"depthWrite":true,"cull":1,"blendType":3,"shadowSampleType":1,"useFog":true,"useLighting":true,"useSkybox":true,"mapping_format":"path"}
\ No newline at end of file
diff --git a/examples/assets/models/playbot/26020285/Playbot_leg.json b/examples/assets/models/playbot/26020285/Playbot_leg.json
index 8fea4d015..d72b76057 100644
--- a/examples/assets/models/playbot/26020285/Playbot_leg.json
+++ b/examples/assets/models/playbot/26020285/Playbot_leg.json
@@ -1,1 +1,1 @@
-{"shader":"blinn","ambient":[0.588,0.588,0.588],"diffuse":[0.588,0.588,0.588],"diffuseMap":"../26020290/leg_clean.png","diffuseMapOffset":[0,0],"diffuseMapTiling":[1,1],"specular":[0.9,0.9,0.9],"shininess":90.9091,"emissive":[0,0,0],"emissiveMap":"../26020284/leg_E.png","emissiveMapOffset":[0,0],"emissiveMapTiling":[1,1],"normalMap":"../26020282/leg_N_clean.png","normalMapOffset":[0,0],"normalMapTiling":[1,1],"bumpMapFactor":0.3,"opacity":1,"sphereMap":"../26020278/env_01.png","reflectivity":0.2,"aoMapChannel":"r","aoMapTiling":[1,1],"aoMapOffset":[0,0],"occludeSpecular":1,"diffuseMapChannel":"rgb","specularMapChannel":"rgb","specularMapTiling":[1,1],"specularMapOffset":[0,0],"specularAntialias":true,"metalnessMapChannel":"r","metalnessMapTiling":[1,1],"metalnessMapOffset":[0,0],"metalness":1,"conserveEnergy":true,"glossMapChannel":"r","glossMapTiling":[1,1],"glossMapOffset":[0,0],"emissiveMapChannel":"rgb","emissiveIntensity":1,"heightMapChannel":"r","heightMapTiling":[1,1],"heightMapOffset":[0,0],"heightMapFactor":1,"opacityMapChannel":"r","opacityMapTiling":[1,1],"opacityMapOffset":[0,0],"refractionIndex":0.6666666666666666,"cubeMapProjectionBox":{"center":[0,0,0],"halfExtents":[0.5,0.5,0.5]},"lightMapChannel":"rgb","lightMapUv":1,"lightMapTiling":[1,1],"lightMapOffset":[0,0],"depthTest":true,"depthWrite":true,"cull":1,"blendType":3,"shadowSampleType":1,"useFog":true,"useLighting":true,"useSkybox":true,"useGammaTonemap":true,"mapping_format":"path"}
\ No newline at end of file
+{"shader":"blinn","ambient":[0.588,0.588,0.588],"diffuse":[0.588,0.588,0.588],"diffuseMap":"../26020290/leg_clean.png","diffuseMapOffset":[0,0],"diffuseMapTiling":[1,1],"specular":[0.9,0.9,0.9],"shininess":90.9091,"emissive":[0,0,0],"emissiveMap":"../26020284/leg_E.png","emissiveMapOffset":[0,0],"emissiveMapTiling":[1,1],"normalMap":"../26020282/leg_N_clean.png","normalMapOffset":[0,0],"normalMapTiling":[1,1],"bumpMapFactor":0.3,"opacity":1,"sphereMap":"../26020278/env_01.png","reflectivity":0.2,"aoMapChannel":"r","aoMapTiling":[1,1],"aoMapOffset":[0,0],"occludeSpecular":1,"diffuseMapChannel":"rgb","specularMapChannel":"rgb","specularMapTiling":[1,1],"specularMapOffset":[0,0],"specularAntialias":true,"metalnessMapChannel":"r","metalnessMapTiling":[1,1],"metalnessMapOffset":[0,0],"metalness":1,"conserveEnergy":true,"glossMapChannel":"r","glossMapTiling":[1,1],"glossMapOffset":[0,0],"emissiveMapChannel":"rgb","emissiveIntensity":1,"heightMapChannel":"r","heightMapTiling":[1,1],"heightMapOffset":[0,0],"heightMapFactor":1,"opacityMapChannel":"r","opacityMapTiling":[1,1],"opacityMapOffset":[0,0],"refractionIndex":0.6666666666666666,"cubeMapProjectionBox":{"center":[0,0,0],"halfExtents":[0.5,0.5,0.5]},"lightMapChannel":"rgb","lightMapUv":1,"lightMapTiling":[1,1],"lightMapOffset":[0,0],"depthTest":true,"depthWrite":true,"cull":1,"blendType":3,"shadowSampleType":1,"useFog":true,"useLighting":true,"useSkybox":true,"mapping_format":"path"}
\ No newline at end of file
diff --git a/examples/src/examples/graphics/ambient-occlusion.example.mjs b/examples/src/examples/graphics/ambient-occlusion.example.mjs
index 191ff0242..3789bb85e 100644
--- a/examples/src/examples/graphics/ambient-occlusion.example.mjs
+++ b/examples/src/examples/graphics/ambient-occlusion.example.mjs
@@ -70,7 +70,7 @@ assetListLoader.load(() => {
     // setup skydome
     app.scene.envAtlas = assets.helipad.resource;
     app.scene.skyboxMip = 2;
-    app.scene.exposure = 1.5;
+    app.scene.exposure = 2.5;
 
     // get the instance of the laboratory
     const laboratoryEntity = assets.laboratory.resource.instantiateRenderEntity({
@@ -196,7 +196,6 @@ assetListLoader.load(() => {
         renderPassCamera.ssaoEnabled = currentOptions.ssaoEnabled;
 
         const composePass = renderPassCamera.composePass;
-        composePass.toneMapping = pc.TONEMAP_NEUTRAL;
         composePass.sharpness = 0;
 
         // and set up these rendering passes to be used by the camera, instead of its default rendering
diff --git a/examples/src/examples/graphics/post-processing.example.mjs b/examples/src/examples/graphics/post-processing.example.mjs
index 014572e00..7dba7145a 100644
--- a/examples/src/examples/graphics/post-processing.example.mjs
+++ b/examples/src/examples/graphics/post-processing.example.mjs
@@ -319,7 +319,7 @@ assetListLoader.load(() => {
         },
         bloom: {
             enabled: currentOptions.bloomEnabled,
-            intensity: 20,
+            intensity: 10,
             lastMipLevel: 1
         },
         grading: {
diff --git a/examples/src/examples/graphics/taa.example.mjs b/examples/src/examples/graphics/taa.example.mjs
index 06acd70bc..109e9b40a 100644
--- a/examples/src/examples/graphics/taa.example.mjs
+++ b/examples/src/examples/graphics/taa.example.mjs
@@ -62,7 +62,7 @@ assetListLoader.load(() => {
     // setup skydome with low intensity
     app.scene.envAtlas = assets.envatlas.resource;
     app.scene.skyboxMip = 0;
-    app.scene.exposure = 1.6;
+    app.scene.exposure = 2.5;
 
     // create an instance of the house and add it to the scene
     const houseEntity = assets.house.resource.instantiateRenderEntity();
diff --git a/src/core/math/color.js b/src/core/math/color.js
index 346a4ddd0..cbf0ea30b 100644
--- a/src/core/math/color.js
+++ b/src/core/math/color.js
@@ -49,7 +49,7 @@ class Color {
             this.r = r[0];
             this.g = r[1];
             this.b = r[2];
-            this.a = r[3] !== undefined ? r[3] : 1;
+            this.a = r[3] ?? 1;
         } else {
             this.r = r;
             this.g = g;
@@ -150,6 +150,49 @@ class Color {
         return this;
     }
 
+    /**
+     * Converts the color from gamma to linear color space.
+     *
+     * @param {Color} [src] - The color to convert to linear color space. If not set, the operation
+     * is done in place.
+     * @returns {Color} Self for chaining.
+     */
+    linear(src = this) {
+        this.r = Math.pow(src.r, 2.2);
+        this.g = Math.pow(src.g, 2.2);
+        this.b = Math.pow(src.b, 2.2);
+        this.a = src.a;
+        return this;
+    }
+
+    /**
+     * Converts the color from linear to gamma color space.
+     *
+     * @param {Color} [src] - The color to convert to gamma color space. If not set, the operation is
+     * done in place.
+     * @returns {Color} Self for chaining.
+     */
+    gamma(src = this) {
+        this.r = Math.pow(src.r, 1 / 2.2);
+        this.g = Math.pow(src.g, 1 / 2.2);
+        this.b = Math.pow(src.b, 1 / 2.2);
+        this.a = src.a;
+        return this;
+    }
+
+    /**
+     * Multiplies RGB elements of a Color by a number. Note that the alpha value is left unchanged.
+     *
+     * @param {number} scalar - The number to multiply by.
+     * @returns {Color} Self for chaining.
+     */
+    mulScalar(scalar) {
+        this.r *= scalar;
+        this.g *= scalar;
+        this.b *= scalar;
+        return this;
+    }
+
     /**
      * Set the values of the color from a string representation '#11223344' or '#112233'.
      *
diff --git a/src/deprecated/deprecated.js b/src/deprecated/deprecated.js
index 6c2e2f3d6..c8e5a7076 100644
--- a/src/deprecated/deprecated.js
+++ b/src/deprecated/deprecated.js
@@ -650,6 +650,17 @@ Object.defineProperty(StandardMaterial.prototype, 'shininess', {
     }
 });
 
+// useGammaTonemap was renamed to useTonemap. For now do not log a deprecated warning to make existing
+// code work without warnings.
+Object.defineProperty(StandardMaterial.prototype, 'useGammaTonemap', {
+    get: function () {
+        return this.useTonemap;
+    },
+    set: function (value) {
+        this.useTonemap = value;
+    }
+});
+
 function _defineAlias(newName, oldName) {
     Object.defineProperty(StandardMaterial.prototype, oldName, {
         get: function () {
diff --git a/src/extras/render-passes/render-pass-camera-frame.js b/src/extras/render-passes/render-pass-camera-frame.js
index c42d313d4..f5e7b1216 100644
--- a/src/extras/render-passes/render-pass-camera-frame.js
+++ b/src/extras/render-passes/render-pass-camera-frame.js
@@ -1,4 +1,4 @@
-import { LAYERID_SKYBOX, LAYERID_IMMEDIATE, TONEMAP_LINEAR, GAMMA_NONE } from '../../scene/constants.js';
+import { LAYERID_SKYBOX, LAYERID_IMMEDIATE, TONEMAP_NONE, GAMMA_NONE } from '../../scene/constants.js';
 import {
     ADDRESS_CLAMP_TO_EDGE,
     FILTER_LINEAR,
@@ -155,14 +155,14 @@ class RenderPassCameraFrame extends RenderPass {
         if (!cameraComponent.rendering) {
             const renderingParams = new RenderingParams();
             renderingParams.gammaCorrection = GAMMA_NONE;
-            renderingParams.toneMapping = TONEMAP_LINEAR;
+            renderingParams.toneMapping = TONEMAP_NONE;
             cameraComponent.rendering = renderingParams;
         }
 
         Debug.call(() => {
             const renderingParams = cameraComponent.rendering;
-            if (renderingParams.gammaCorrection !== GAMMA_NONE || renderingParams.toneMapping !== TONEMAP_LINEAR) {
-                Debug.error('Camera rendering parameters are not set to HDR mode');
+            if (renderingParams.gammaCorrection !== GAMMA_NONE || renderingParams.toneMapping !== TONEMAP_NONE) {
+                Debug.error('Camera rendering parameters are not set to HDR mode: GAMMA_NONE and TONEMAP_NONE');
             }
         });
 
diff --git a/src/extras/render-passes/render-pass-compose.js b/src/extras/render-passes/render-pass-compose.js
index b99f6b1aa..64a064893 100644
--- a/src/extras/render-passes/render-pass-compose.js
+++ b/src/extras/render-passes/render-pass-compose.js
@@ -2,7 +2,8 @@ import { math } from '../../core/math/math.js';
 import { Color } from '../../core/math/color.js';
 import { RenderPassShaderQuad } from '../../scene/graphics/render-pass-shader-quad.js';
 import { shaderChunks } from '../../scene/shader-lib/chunks/chunks.js';
-import { TONEMAP_LINEAR, TONEMAP_FILMIC, TONEMAP_HEJL, TONEMAP_ACES, TONEMAP_ACES2, TONEMAP_NEUTRAL } from '../../scene/constants.js';
+import { TONEMAP_LINEAR } from '../../scene/constants.js';
+import { ShaderGenerator } from '../../scene/shader-lib/programs/shader-generator.js';
 
 
 // Contrast Adaptive Sharpening (CAS) is used to apply the sharpening. It's based on AMD's
@@ -176,7 +177,7 @@ class RenderPassCompose extends RenderPassShaderQuad {
 
     _ssaoTexture = null;
 
-    _toneMapping = TONEMAP_ACES2;
+    _toneMapping = TONEMAP_LINEAR;
 
     _gradingEnabled = false;
 
@@ -301,18 +302,6 @@ class RenderPassCompose extends RenderPassShaderQuad {
         return this._toneMapping;
     }
 
-    get toneMapChunk() {
-        switch (this.toneMapping) {
-            case TONEMAP_LINEAR: return shaderChunks.tonemappingLinearPS;
-            case TONEMAP_FILMIC: return shaderChunks.tonemappingFilmicPS;
-            case TONEMAP_HEJL: return shaderChunks.tonemappingHejlPS;
-            case TONEMAP_ACES: return shaderChunks.tonemappingAcesPS;
-            case TONEMAP_ACES2: return shaderChunks.tonemappingAces2PS;
-            case TONEMAP_NEUTRAL: return shaderChunks.tonemappingNeutralPS;
-        }
-        return shaderChunks.tonemappingNonePS;
-    }
-
     set sharpness(value) {
         if (this._sharpness !== value) {
             this._sharpness = value;
@@ -364,7 +353,7 @@ class RenderPassCompose extends RenderPassShaderQuad {
                 const fsChunks =
                 shaderChunks.decodePS +
                 shaderChunks.gamma2_2PS +
-                this.toneMapChunk;
+                ShaderGenerator.tonemapCode(this.toneMapping);
 
                 this.shader = this.createQuadShader(`ComposeShader-${key}`, defines + fsChunks + fragmentShader);
             }
diff --git a/src/framework/components/element/system.js b/src/framework/components/element/system.js
index ffbe07006..1573c95ba 100644
--- a/src/framework/components/element/system.js
+++ b/src/framework/components/element/system.js
@@ -372,7 +372,7 @@ class ElementComponentSystem extends ComponentSystem {
         //  defaultScreenSpaceBitmapTextMaterial
         material.name = 'default' + name;
         material.useLighting = false;
-        material.useGammaTonemap = false;
+        material.useTonemap = false;
         material.useFog = false;
         material.useSkybox = false;
         material.diffuse.set(0, 0, 0); // black diffuse color to prevent ambient light being included
@@ -399,7 +399,7 @@ class ElementComponentSystem extends ComponentSystem {
         material.opacityTint = true;
         material.opacity = 0; // use non-1 opacity to compile shader correctly
         material.useLighting = false;
-        material.useGammaTonemap = false;
+        material.useTonemap = false;
         material.useFog = false;
         material.useSkybox = false;
         material.blendType = BLEND_PREMULTIPLIED;
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/components/sprite/system.js b/src/framework/components/sprite/system.js
index 27399cfa7..128811b28 100644
--- a/src/framework/components/sprite/system.js
+++ b/src/framework/components/sprite/system.js
@@ -81,7 +81,7 @@ class SpriteComponentSystem extends ComponentSystem {
             material.opacityTint = true;
             material.opacity = 0; // use non-1 opacity to compile shader correctly
             material.useLighting = false;
-            material.useGammaTonemap = false;
+            material.useTonemap = false;
             material.useFog = false;
             material.useSkybox = false;
             material.blendType = BLEND_PREMULTIPLIED;
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/lightmapper/bake-light-ambient.js b/src/framework/lightmapper/bake-light-ambient.js
index c3f627c03..83047a9cc 100644
--- a/src/framework/lightmapper/bake-light-ambient.js
+++ b/src/framework/lightmapper/bake-light-ambient.js
@@ -45,7 +45,7 @@ class BakeLightAmbient extends BakeLight {
 
         // intensity of the virtual light depends on the sphere part used, and also needs to take into account
         // the fact N dot L used to bake it lowers total intensity
-        const gamma = this.scene.rendering.gammaCorrection ? 2.2 : 1;
+        const gamma = 2.2;
         const fullIntensity = 2 * Math.PI * this.scene.ambientBakeSpherePart;
         const linearIntensity = Math.pow(fullIntensity, gamma);
         this.light.intensity = Math.pow(linearIntensity / numVirtualLights, 1 / gamma);
diff --git a/src/framework/lightmapper/bake-light-simple.js b/src/framework/lightmapper/bake-light-simple.js
index c398bdbe2..ed5fe9ca7 100644
--- a/src/framework/lightmapper/bake-light-simple.js
+++ b/src/framework/lightmapper/bake-light-simple.js
@@ -34,7 +34,7 @@ class BakeLightSimple extends BakeLight {
         light._node.getWorldTransform();
 
         // divide intensity by number of virtual lights (in linear space)
-        const gamma = this.scene.rendering.gammaCorrection ? 2.2 : 1;
+        const gamma = 2.2;
         const linearIntensity = Math.pow(this.intensity, gamma);
         light.intensity = Math.pow(linearIntensity / numVirtualLights, 1 / gamma);
     }
diff --git a/src/framework/lightmapper/lightmapper.js b/src/framework/lightmapper/lightmapper.js
index 135ac3cf2..e4ba7b02f 100644
--- a/src/framework/lightmapper/lightmapper.js
+++ b/src/framework/lightmapper/lightmapper.js
@@ -1043,7 +1043,7 @@ class Lightmapper {
                     shadowMapRendered = this.renderShadowMap(comp, shadowMapRendered, casters, bakeLight);
 
                     if (clusteredLightingEnabled) {
-                        this.worldClusters.update(clusterLights, this.scene.rendering.gammaCorrection, this.lightingParams);
+                        this.worldClusters.update(clusterLights, this.lightingParams);
                     }
 
                     // Store original materials
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..b8e9840c0 100644
--- a/src/index.js
+++ b/src/index.js
@@ -184,6 +184,7 @@ export { SphereGeometry } from './scene/geometry/sphere-geometry.js';
 export { TorusGeometry } from './scene/geometry/torus-geometry.js';
 
 // SCENE / RENDERER
+export { RenderingParams } from './scene/renderer/rendering-params.js';
 export { RenderPassForward } from './scene/renderer/render-pass-forward.js';
 
 // SCENE / SHADER-LIB
@@ -356,10 +357,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/constants.js b/src/scene/constants.js
index 68f9f8d27..b7f2b635d 100644
--- a/src/scene/constants.js
+++ b/src/scene/constants.js
@@ -579,7 +579,7 @@ export const GAMMA_NONE = 0;
 export const GAMMA_SRGB = 1;
 
 /**
- * Linear tonemapping.
+ * Linear tonemapping. The colors are preserved, but the exposure is applied.
  *
  * @type {number}
  * @category Graphics
@@ -626,6 +626,14 @@ export const TONEMAP_ACES2 = 4;
  */
 export const TONEMAP_NEUTRAL = 5;
 
+/**
+ * No tonemapping or exposure is applied. Used for HDR rendering.
+ *
+ * @type {number}
+ * @category Graphics
+ */
+export const TONEMAP_NONE = 6;
+
 /**
  * No specular occlusion.
  *
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;
diff --git a/src/scene/light.js b/src/scene/light.js
index 0c091e117..13d49e929 100644
--- a/src/scene/light.js
+++ b/src/scene/light.js
@@ -21,6 +21,7 @@ const tmpBiases = {
     bias: 0,
     normalBias: 0
 };
+const tmpColor = new Color();
 
 const chanId = { r: 0, g: 1, b: 2, a: 3 };
 
@@ -147,7 +148,7 @@ class Light {
 
         // Light properties (defaults)
         this._type = LIGHTTYPE_DIRECTIONAL;
-        this._color = new Color(0.8, 0.8, 0.8);
+        this._color = new Color(0.8, 0.8, 0.8);     // color in sRGB space
         this._intensity = 1;
         this._affectSpecularity = true;
         this._luminance = 0;
@@ -193,10 +194,9 @@ class Light {
         // Light source shape properties
         this._shape = LIGHTSHAPE_PUNCTUAL;
 
-        // Cache of light property data in a format more friendly for shader uniforms
-        this._finalColor = new Float32Array([0.8, 0.8, 0.8]);
-        const c = Math.pow(this._finalColor[0], 2.2);
-        this._linearFinalColor = new Float32Array([c, c, c]);
+        // light color and intensity in the linear space
+        this._colorLinear = new Float32Array(3);
+        this._updateLinearColor();
 
         this._position = new Vec3(0, 0, 0);
         this._direction = new Vec3(0, 0, 0);
@@ -371,7 +371,7 @@ class Light {
     set usePhysicalUnits(value) {
         if (this._usePhysicalUnits !== value) {
             this._usePhysicalUnits = value;
-            this._updateFinalColor();
+            this._updateLinearColor();
         }
     }
 
@@ -493,7 +493,7 @@ class Light {
         this._innerConeAngle = value;
         this._innerConeAngleCos = Math.cos(value * Math.PI / 180);
         if (this._usePhysicalUnits) {
-            this._updateFinalColor();
+            this._updateLinearColor();
         }
     }
 
@@ -509,7 +509,7 @@ class Light {
         this._updateOuterAngle(value);
 
         if (this._usePhysicalUnits) {
-            this._updateFinalColor();
+            this._updateLinearColor();
         }
     }
 
@@ -534,7 +534,7 @@ class Light {
     set intensity(value) {
         if (this._intensity !== value) {
             this._intensity = value;
-            this._updateFinalColor();
+            this._updateLinearColor();
         }
     }
 
@@ -556,7 +556,7 @@ class Light {
     set luminance(value) {
         if (this._luminance !== value) {
             this._luminance = value;
-            this._updateFinalColor();
+            this._updateLinearColor();
         }
     }
 
@@ -897,34 +897,28 @@ class Light {
         }
     }
 
-    _updateFinalColor() {
-        const color = this._color;
-        const r = color.r;
-        const g = color.g;
-        const b = color.b;
+    _updateLinearColor() {
 
-        let i = this._intensity;
+        let intensity = this._intensity;
 
         // To calculate the lux, which is lm/m^2, we need to convert from luminous power
         if (this._usePhysicalUnits) {
-            i = this._luminance / Light.getLightUnitConversion(this._type, this._outerConeAngle * math.DEG_TO_RAD, this._innerConeAngle * math.DEG_TO_RAD);
+            intensity = this._luminance / Light.getLightUnitConversion(this._type, this._outerConeAngle * math.DEG_TO_RAD, this._innerConeAngle * math.DEG_TO_RAD);
         }
 
-        const finalColor = this._finalColor;
-        const linearFinalColor = this._linearFinalColor;
-
-        finalColor[0] = r * i;
-        finalColor[1] = g * i;
-        finalColor[2] = b * i;
-        if (i >= 1) {
-            linearFinalColor[0] = Math.pow(r, 2.2) * i;
-            linearFinalColor[1] = Math.pow(g, 2.2) * i;
-            linearFinalColor[2] = Math.pow(b, 2.2) * i;
+        // Note: This is slightly unconventional, ideally we'd convert color to linear space and then
+        // multiply by intensity, but keeping this for backwards compatibility
+        const color = this._color;
+        const colorLinear = this._colorLinear;
+        if (intensity >= 1) {
+            tmpColor.linear(color).mulScalar(intensity);
         } else {
-            linearFinalColor[0] = Math.pow(finalColor[0], 2.2);
-            linearFinalColor[1] = Math.pow(finalColor[1], 2.2);
-            linearFinalColor[2] = Math.pow(finalColor[2], 2.2);
+            tmpColor.copy(color).mulScalar(intensity).linear();
         }
+
+        colorLinear[0] = tmpColor.r;
+        colorLinear[1] = tmpColor.g;
+        colorLinear[2] = tmpColor.b;
     }
 
     setColor() {
@@ -934,7 +928,7 @@ class Light {
             this._color.set(arguments[0], arguments[1], arguments[2]);
         }
 
-        this._updateFinalColor();
+        this._updateLinearColor();
     }
 
     layersDirty() {
diff --git a/src/scene/lighting/lights-buffer.js b/src/scene/lighting/lights-buffer.js
index 7fa512375..b4af22043 100644
--- a/src/scene/lighting/lights-buffer.js
+++ b/src/scene/lighting/lights-buffer.js
@@ -189,9 +189,9 @@ class LightsBuffer {
         data8[index + 3] = castShadows ? shadowIntensity * 255 : 0;
     }
 
-    addLightDataColor(data8, index, light, gammaCorrection, isCookie) {
+    addLightDataColor(data8, index, light, isCookie) {
         const invMaxColorValue = this.invMaxColorValue;
-        const color = gammaCorrection ? light._linearFinalColor : light._finalColor;
+        const color = light._colorLinear;
         FloatPacking.float2Bytes(color[0] * invMaxColorValue, data8, index + 0, 2);
         FloatPacking.float2Bytes(color[1] * invMaxColorValue, data8, index + 2, 2);
         FloatPacking.float2Bytes(color[2] * invMaxColorValue, data8, index + 4, 2);
@@ -237,7 +237,7 @@ class LightsBuffer {
     }
 
     // fill up both float and 8bit texture data with light properties
-    addLightData(light, lightIndex, gammaCorrection) {
+    addLightData(light, lightIndex) {
 
         const isSpot = light._type === LIGHTTYPE_SPOT;
         const hasAtlasViewport = light.atlasViewportAllocated; // if the light does not have viewport, it does not fit to the atlas
@@ -269,7 +269,7 @@ class LightsBuffer {
         this.addLightDataFlags(data8, data8Start + 4 * TextureIndex8.FLAGS, light, isSpot, castShadows, light.shadowIntensity);
 
         // light color
-        this.addLightDataColor(data8, data8Start + 4 * TextureIndex8.COLOR_A, light, gammaCorrection, isCookie);
+        this.addLightDataColor(data8, data8Start + 4 * TextureIndex8.COLOR_A, light, isCookie);
 
         // spot light angles
         if (isSpot) {
diff --git a/src/scene/lighting/world-clusters.js b/src/scene/lighting/world-clusters.js
index 49e87054b..70447cd22 100644
--- a/src/scene/lighting/world-clusters.js
+++ b/src/scene/lighting/world-clusters.js
@@ -347,7 +347,7 @@ class WorldClusters {
     }
 
     // evaluate ranges of variables compressed to 8bit texture to allow their scaling to 0..1 range
-    evaluateCompressionLimits(gammaCorrection) {
+    evaluateCompressionLimits() {
 
         let maxAttenuation = 0;
         let maxColorValue = 0;
@@ -357,7 +357,7 @@ class WorldClusters {
             const light = usedLights[i].light;
             maxAttenuation = Math.max(light.attenuationEnd, maxAttenuation);
 
-            const color = gammaCorrection ? light._linearFinalColor : light._finalColor;
+            const color = light._colorLinear;
             maxColorValue = Math.max(color[0], maxColorValue);
             maxColorValue = Math.max(color[1], maxColorValue);
             maxColorValue = Math.max(color[2], maxColorValue);
@@ -370,7 +370,7 @@ class WorldClusters {
         this.lightsBuffer.setCompressionRanges(this._maxAttenuation, this._maxColorValue);
     }
 
-    updateClusters(gammaCorrection) {
+    updateClusters() {
 
         // clear clusters
         this.counts.fill(0);
@@ -392,7 +392,7 @@ class WorldClusters {
             const light = clusteredLight.light;
 
             // add light data into textures
-            this.lightsBuffer.addLightData(light, i, gammaCorrection);
+            this.lightsBuffer.addLightData(light, i);
 
             // light's bounds in cell space
             this.evalLightCellMinMax(clusteredLight, tempMin3, tempMax3);
@@ -436,13 +436,13 @@ class WorldClusters {
     }
 
     // internal update of the cluster data, executes once per frame
-    update(lights, gammaCorrection, lightingParams) {
+    update(lights, lightingParams) {
         this.updateParams(lightingParams);
         this.updateCells();
         this.collectLights(lights);
         this.evaluateBounds();
-        this.evaluateCompressionLimits(gammaCorrection);
-        this.updateClusters(gammaCorrection);
+        this.evaluateCompressionLimits();
+        this.updateClusters();
         this.uploadTextures();
     }
 
diff --git a/src/scene/materials/lit-material-options-builder.js b/src/scene/materials/lit-material-options-builder.js
index 1f74cc7ef..e1d6adaf4 100644
--- a/src/scene/materials/lit-material-options-builder.js
+++ b/src/scene/materials/lit-material-options-builder.js
@@ -1,6 +1,6 @@
 import {
-    CUBEPROJ_NONE, GAMMA_NONE, LIGHTTYPE_DIRECTIONAL, LIGHTTYPE_OMNI, LIGHTTYPE_SPOT,
-    MASK_AFFECT_DYNAMIC, TONEMAP_LINEAR, SHADERDEF_INSTANCING, SHADERDEF_MORPH_NORMAL,
+    CUBEPROJ_NONE, LIGHTTYPE_DIRECTIONAL, LIGHTTYPE_OMNI, LIGHTTYPE_SPOT,
+    MASK_AFFECT_DYNAMIC, TONEMAP_NONE, SHADERDEF_INSTANCING, SHADERDEF_MORPH_NORMAL,
     SHADERDEF_MORPH_POSITION, SHADERDEF_MORPH_TEXTURE_BASED, SHADERDEF_SCREENSPACE, SHADERDEF_SKIN,
     SHADERDEF_NOSHADOW, SHADERDEF_TANGENTS, SPECULAR_BLINN, SPRITE_RENDERMODE_SIMPLE
 } from "../constants.js";
@@ -92,8 +92,8 @@ class LitMaterialOptionsBuilder {
 
     static updateEnvOptions(litOptions, material, scene, renderParams) {
         litOptions.fog = material.useFog ? scene.fog : 'none';
-        litOptions.gamma = material.useGammaTonemap ? renderParams.gammaCorrection : GAMMA_NONE;
-        litOptions.toneMap = material.useGammaTonemap ? renderParams.toneMapping : TONEMAP_LINEAR;
+        litOptions.gamma = renderParams.gammaCorrection;
+        litOptions.toneMap = material.useTonemap ? renderParams.toneMapping : TONEMAP_NONE;
 
         // source of reflections
         if (material.useSkybox && scene.envAtlas && scene.skybox) {
diff --git a/src/scene/materials/lit-material.js b/src/scene/materials/lit-material.js
index 1f3847edd..17ca97e8f 100644
--- a/src/scene/materials/lit-material.js
+++ b/src/scene/materials/lit-material.js
@@ -29,7 +29,7 @@ class LitMaterial extends Material {
 
     useFog = true;
 
-    useGammaTonemap = true;
+    useTonemap = true;
 
     useSkybox = true;
 
diff --git a/src/scene/materials/standard-material-options-builder.js b/src/scene/materials/standard-material-options-builder.js
index cd98e21d8..3d6cfadc3 100644
--- a/src/scene/materials/standard-material-options-builder.js
+++ b/src/scene/materials/standard-material-options-builder.js
@@ -4,13 +4,12 @@ import {
 
 import {
     BLEND_NONE,
-    GAMMA_NONE,
     LIGHTTYPE_DIRECTIONAL, LIGHTTYPE_OMNI, LIGHTTYPE_SPOT,
     MASK_AFFECT_DYNAMIC,
     SHADER_PREPASS_VELOCITY,
     SHADERDEF_DIRLM, SHADERDEF_INSTANCING, SHADERDEF_LM, SHADERDEF_MORPH_POSITION, SHADERDEF_MORPH_NORMAL, SHADERDEF_NOSHADOW, SHADERDEF_MORPH_TEXTURE_BASED,
     SHADERDEF_SCREENSPACE, SHADERDEF_SKIN, SHADERDEF_TANGENTS, SHADERDEF_UV0, SHADERDEF_UV1, SHADERDEF_VCOLOR, SHADERDEF_LMAMBIENT,
-    TONEMAP_LINEAR,
+    TONEMAP_NONE,
     SPECULAR_PHONG,
     DITHER_NONE
 } from '../constants.js';
@@ -295,8 +294,8 @@ class StandardMaterialOptionsBuilder {
 
     _updateEnvOptions(options, stdMat, scene, renderParams) {
         options.litOptions.fog = stdMat.useFog ? scene.fog : 'none';
-        options.litOptions.gamma = stdMat.useGammaTonemap ? renderParams.gammaCorrection : GAMMA_NONE;
-        options.litOptions.toneMap = stdMat.useGammaTonemap ? renderParams.toneMapping : TONEMAP_LINEAR;
+        options.litOptions.gamma = renderParams.gammaCorrection;
+        options.litOptions.toneMap = stdMat.useTonemap ? renderParams.toneMapping : TONEMAP_NONE;
 
         const isPhong = stdMat.shadingModel === SPECULAR_PHONG;
 
diff --git a/src/scene/materials/standard-material-parameters.js b/src/scene/materials/standard-material-parameters.js
index 1f60923c1..70b2f0040 100644
--- a/src/scene/materials/standard-material-parameters.js
+++ b/src/scene/materials/standard-material-parameters.js
@@ -140,7 +140,7 @@ const standardMaterialParameterTypes = {
     useFog: 'boolean',
     useLighting: 'boolean',
     useSkybox: 'boolean',
-    useGammaTonemap: 'boolean',
+    useTonemap: 'boolean',
 
     envAtlas: 'texture',
 
diff --git a/src/scene/materials/standard-material.js b/src/scene/materials/standard-material.js
index 5426e361c..9254016d0 100644
--- a/src/scene/materials/standard-material.js
+++ b/src/scene/materials/standard-material.js
@@ -32,6 +32,8 @@ const _uniforms = {};
 // temporary set of params
 let _params = new Set();
 
+const _tempColor = new Color();
+
 /**
  * Callback used by {@link StandardMaterial#onUpdateShader}.
  *
@@ -537,8 +539,8 @@ let _params = new Set();
  * @property {boolean} useFog Apply fogging (as configured in scene settings)
  * @property {boolean} useLighting Apply lighting
  * @property {boolean} useSkybox Apply scene skybox as prefiltered environment map
- * @property {boolean} useGammaTonemap Apply gamma correction and tonemapping (as configured in
- * scene settings).
+ * @property {boolean} useTonemap Apply tonemapping (as configured in {@link Scene#rendering} or
+ * {@link CameraComponent.rendering}). Defaults to true.
  * @property {boolean} pixelSnap Align vertices to pixel coordinates when rendering. Useful for
  * pixel perfect 2D graphics.
  * @property {boolean} twoSidedLighting Calculate proper normals (and therefore lighting) on
@@ -1096,17 +1098,12 @@ function _defineColor(name, defaultValue) {
     defineUniform(name, (material, device, scene) => {
         const uniform = material._allocUniform(name, () => new Float32Array(3));
         const color = material[name];
-        const gamma = material.useGammaTonemap && scene.rendering.gammaCorrection;
 
-        if (gamma) {
-            uniform[0] = Math.pow(color.r, 2.2);
-            uniform[1] = Math.pow(color.g, 2.2);
-            uniform[2] = Math.pow(color.b, 2.2);
-        } else {
-            uniform[0] = color.r;
-            uniform[1] = color.g;
-            uniform[2] = color.b;
-        }
+        // uniforms are always in linear space
+        _tempColor.linear(color);
+        uniform[0] = _tempColor.r;
+        uniform[1] = _tempColor.g;
+        uniform[2] = _tempColor.b;
 
         return uniform;
     });
@@ -1243,7 +1240,7 @@ function _defineMaterialProps() {
     _defineFlag('customFragmentShader', null);
     _defineFlag('useFog', true);
     _defineFlag('useLighting', true);
-    _defineFlag('useGammaTonemap', true);
+    _defineFlag('useTonemap', true);
     _defineFlag('useSkybox', true);
     _defineFlag('forceUv1', false);
     _defineFlag('pixelSnap', false);
diff --git a/src/scene/renderer/forward-renderer.js b/src/scene/renderer/forward-renderer.js
index fa6bdbbc7..68947d42f 100644
--- a/src/scene/renderer/forward-renderer.js
+++ b/src/scene/renderer/forward-renderer.js
@@ -2,6 +2,7 @@ import { now } from '../../core/time.js';
 import { Debug } from '../../core/debug.js';
 
 import { Vec3 } from '../../core/math/vec3.js';
+import { Color } from '../../core/math/color.js';
 
 import { DebugGraphics } from '../../platform/graphics/debug-graphics.js';
 
@@ -19,6 +20,7 @@ import { RenderPassForward } from './render-pass-forward.js';
 import { RenderPassPostprocessing } from './render-pass-postprocessing.js';
 
 const _noLights = [[], [], []];
+const tmpColor = new Color();
 
 const _drawCallList = {
     drawCalls: [],
@@ -144,20 +146,20 @@ class ForwardRenderer extends Renderer {
      * @param {import('../scene.js').Scene} scene - The scene.
      */
     dispatchGlobalLights(scene) {
-        this.ambientColor[0] = scene.ambientLight.r;
-        this.ambientColor[1] = scene.ambientLight.g;
-        this.ambientColor[2] = scene.ambientLight.b;
-        if (scene.rendering.gammaCorrection) {
-            for (let i = 0; i < 3; i++) {
-                this.ambientColor[i] = Math.pow(this.ambientColor[i], 2.2);
-            }
-        }
+        const ambientUniform = this.ambientColor;
+
+        // color in linear space
+        tmpColor.linear(scene.ambientLight);
+        ambientUniform[0] = tmpColor.r;
+        ambientUniform[1] = tmpColor.g;
+        ambientUniform[2] = tmpColor.b;
+
         if (scene.physicalUnits) {
             for (let i = 0; i < 3; i++) {
-                this.ambientColor[i] *= scene.ambientLuminance;
+                ambientUniform[i] *= scene.ambientLuminance;
             }
         }
-        this.ambientId.setValue(this.ambientColor);
+        this.ambientId.setValue(ambientUniform);
 
         this.skyboxIntensityId.setValue(scene.physicalUnits ? scene.skyboxLuminance : scene.skyboxIntensity);
         this.cubeMapRotationMatrixId.setValue(scene._skyboxRotationMat3.data);
@@ -213,7 +215,7 @@ class ForwardRenderer extends Renderer {
         this.lightHeightId[cnt].setValue(this.lightHeight[cnt]);
     }
 
-    dispatchDirectLights(dirs, scene, mask, camera) {
+    dispatchDirectLights(dirs, mask, camera) {
         let cnt = 0;
 
         const scope = this.device.scope;
@@ -228,7 +230,7 @@ class ForwardRenderer extends Renderer {
                 this._resolveLight(scope, cnt);
             }
 
-            this.lightColorId[cnt].setValue(scene.rendering.gammaCorrection ? directional._linearFinalColor : directional._finalColor);
+            this.lightColorId[cnt].setValue(directional._colorLinear);
 
             // Directional lights shine down the negative Y axis
             wtm.getY(directional._direction).mulScalar(-1);
@@ -295,7 +297,7 @@ class ForwardRenderer extends Renderer {
         this.lightHeightId[cnt].setValue(this.lightHeight[cnt]);
     }
 
-    dispatchOmniLight(scene, scope, omni, cnt) {
+    dispatchOmniLight(scope, omni, cnt) {
         const wtm = omni._node.getWorldTransform();
 
         if (!this.lightColorId[cnt]) {
@@ -303,7 +305,7 @@ class ForwardRenderer extends Renderer {
         }
 
         this.lightRadiusId[cnt].setValue(omni.attenuationEnd);
-        this.lightColorId[cnt].setValue(scene.render.gammaCorrection ? omni._linearFinalColor : omni._finalColor);
+        this.lightColorId[cnt].setValue(omni._colorLinear);
         wtm.getTranslation(omni._position);
         this.lightPos[cnt][0] = omni._position.x;
         this.lightPos[cnt][1] = omni._position.y;
@@ -349,7 +351,7 @@ class ForwardRenderer extends Renderer {
         }
     }
 
-    dispatchSpotLight(scene, scope, spot, cnt) {
+    dispatchSpotLight(scope, spot, cnt) {
         const wtm = spot._node.getWorldTransform();
 
         if (!this.lightColorId[cnt]) {
@@ -359,7 +361,7 @@ class ForwardRenderer extends Renderer {
         this.lightInAngleId[cnt].setValue(spot._innerConeAngleCos);
         this.lightOutAngleId[cnt].setValue(spot._outerConeAngleCos);
         this.lightRadiusId[cnt].setValue(spot.attenuationEnd);
-        this.lightColorId[cnt].setValue(scene.rendering.gammaCorrection ? spot._linearFinalColor : spot._finalColor);
+        this.lightColorId[cnt].setValue(spot._colorLinear);
         wtm.getTranslation(spot._position);
         this.lightPos[cnt][0] = spot._position.x;
         this.lightPos[cnt][1] = spot._position.y;
@@ -434,7 +436,7 @@ class ForwardRenderer extends Renderer {
         }
     }
 
-    dispatchLocalLights(sortedLights, scene, mask, usedDirLights) {
+    dispatchLocalLights(sortedLights, mask, usedDirLights) {
 
         let cnt = usedDirLights;
         const scope = this.device.scope;
@@ -444,7 +446,7 @@ class ForwardRenderer extends Renderer {
         for (let i = 0; i < numOmnis; i++) {
             const omni = omnis[i];
             if (!(omni.mask & mask)) continue;
-            this.dispatchOmniLight(scene, scope, omni, cnt);
+            this.dispatchOmniLight(scope, omni, cnt);
             cnt++;
         }
 
@@ -453,7 +455,7 @@ class ForwardRenderer extends Renderer {
         for (let i = 0; i < numSpts; i++) {
             const spot = spts[i];
             if (!(spot.mask & mask)) continue;
-            this.dispatchSpotLight(scene, scope, spot, cnt);
+            this.dispatchSpotLight(scope, spot, cnt);
             cnt++;
         }
     }
@@ -536,7 +538,7 @@ class ForwardRenderer extends Renderer {
         const scene = this.scene;
         const passFlag = 1 << pass;
         const flipFactor = flipFaces ? -1 : 1;
-        const clusteredLightingEnabled = this.scene.clusteredLightingEnabled;
+        const clusteredLightingEnabled = scene.clusteredLightingEnabled;
 
         // Render the scene
         const preparedCallsCount = preparedCalls.drawCalls.length;
@@ -560,10 +562,10 @@ class ForwardRenderer extends Renderer {
                 material.setParameters(device);
 
                 if (lightMaskChanged) {
-                    const usedDirLights = this.dispatchDirectLights(sortedLights[LIGHTTYPE_DIRECTIONAL], scene, lightMask, camera);
+                    const usedDirLights = this.dispatchDirectLights(sortedLights[LIGHTTYPE_DIRECTIONAL], lightMask, camera);
 
                     if (!clusteredLightingEnabled) {
-                        this.dispatchLocalLights(sortedLights, scene, lightMask, usedDirLights);
+                        this.dispatchLocalLights(sortedLights, lightMask, usedDirLights);
                     }
                 }
 
@@ -777,15 +779,15 @@ class ForwardRenderer extends Renderer {
 
         // Set up the fog
         if (scene.fog !== FOG_NONE) {
-            this.fogColor[0] = scene.fogColor.r;
-            this.fogColor[1] = scene.fogColor.g;
-            this.fogColor[2] = scene.fogColor.b;
-            if (scene.rendering.gammaCorrection) {
-                for (let i = 0; i < 3; i++) {
-                    this.fogColor[i] = Math.pow(this.fogColor[i], 2.2);
-                }
-            }
-            this.fogColorId.setValue(this.fogColor);
+
+            // color in linear space
+            tmpColor.linear(scene.fogColor);
+            const fogUniform = this.fogColor;
+            fogUniform[0] = tmpColor.r;
+            fogUniform[1] = tmpColor.g;
+            fogUniform[2] = tmpColor.b;
+            this.fogColorId.setValue(fogUniform);
+
             if (scene.fog === FOG_LINEAR) {
                 this.fogStartId.setValue(scene.fogStart);
                 this.fogEndId.setValue(scene.fogEnd);
diff --git a/src/scene/renderer/render-pass-update-clustered.js b/src/scene/renderer/render-pass-update-clustered.js
index 42b1ae8f2..2566e921e 100644
--- a/src/scene/renderer/render-pass-update-clustered.js
+++ b/src/scene/renderer/render-pass-update-clustered.js
@@ -51,7 +51,7 @@ class RenderPassUpdateClustered extends RenderPass {
 
         const { renderer } = this;
         const { scene } = renderer;
-        renderer.worldClustersAllocator.update(this.frameGraph.renderPasses, scene.rendering.gammaCorrection, scene.lighting);
+        renderer.worldClustersAllocator.update(this.frameGraph.renderPasses, scene.lighting);
 
         // #if _PROFILER
         renderer._lightClustersTime += now() - startTime;
diff --git a/src/scene/renderer/world-clusters-allocator.js b/src/scene/renderer/world-clusters-allocator.js
index ffce8e504..88877a1cf 100644
--- a/src/scene/renderer/world-clusters-allocator.js
+++ b/src/scene/renderer/world-clusters-allocator.js
@@ -137,7 +137,7 @@ class WorldClustersAllocator {
         tempClusterArray.length = 0;
     }
 
-    update(renderPasses, gammaCorrection, lighting) {
+    update(renderPasses, lighting) {
 
         // assign clusters to render actions
         this.assign(renderPasses);
@@ -146,7 +146,7 @@ class WorldClustersAllocator {
         this._clusters.forEach((renderAction) => {
             const layer = renderAction.layer;
             const cluster = renderAction.lightClusters;
-            cluster.update(layer.clusteredLightsSet, gammaCorrection, lighting);
+            cluster.update(layer.clusteredLightsSet, lighting);
         });
     }
 }
diff --git a/src/scene/scene.js b/src/scene/scene.js
index 66a1d268e..240e84132 100644
--- a/src/scene/scene.js
+++ b/src/scene/scene.js
@@ -86,7 +86,8 @@ class Scene extends EventHandler {
     ambientBakeOcclusionContrast = 0;
 
     /**
-     * The color of the scene's ambient light. Defaults to black (0, 0, 0).
+     * The color of the scene's ambient light, specified in sRGB color space. Defaults to black
+     * (0, 0, 0).
      *
      * @type {Color}
      */
@@ -107,7 +108,7 @@ class Scene extends EventHandler {
     exposure = 1;
 
     /**
-     * The color of the fog (if enabled). Defaults to black (0, 0, 0).
+     * The color of the fog (if enabled), specified in sRGB color space. Defaults to black (0, 0, 0).
      *
      * @type {Color}
      */
diff --git a/src/scene/shader-lib/programs/standard.js b/src/scene/shader-lib/programs/standard.js
index 659d496a3..3e84f7f27 100644
--- a/src/scene/shader-lib/programs/standard.js
+++ b/src/scene/shader-lib/programs/standard.js
@@ -151,7 +151,7 @@ class ShaderGeneratorStandard extends ShaderGenerator {
                     // is never decoded).
                     subCode = subCode.replace(/\$DECODE/g, 'passThrough');
                 } else {
-                    subCode = subCode.replace(/\$DECODE/g, ChunkUtils.decodeFunc((!options.litOptions.gamma && encoding === 'srgb') ? 'linear' : encoding));
+                    subCode = subCode.replace(/\$DECODE/g, ChunkUtils.decodeFunc(encoding));
                 }
 
                 // continue to support $texture2DSAMPLE
diff --git a/utils/plugins/rollup-types-fixup.mjs b/utils/plugins/rollup-types-fixup.mjs
index 8bc60ebd7..f26091e41 100644
--- a/utils/plugins/rollup-types-fixup.mjs
+++ b/utils/plugins/rollup-types-fixup.mjs
@@ -185,7 +185,7 @@ const STANDARD_MAT_PROPS = [
     ['sphereMap', 'Texture|null'],
     ['twoSidedLighting', 'boolean'],
     ['useFog', 'boolean'],
-    ['useGammaTonemap', 'boolean'],
+    ['useTonemap', 'boolean'],
     ['useLighting', 'boolean'],
     ['useMetalness', 'boolean'],
     ['useMetalnessSpecularColor', 'boolean'],


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';
diff --git a/test/scene/materials/standard-material.test.mjs b/test/scene/materials/standard-material.test.mjs
index df3c8f26e..768a92efe 100644
--- a/test/scene/materials/standard-material.test.mjs
+++ b/test/scene/materials/standard-material.test.mjs
@@ -281,7 +281,7 @@ describe('StandardMaterial', function () {
         expect(material.twoSidedLighting).to.equal(false);
 
         expect(material.useFog).to.equal(true);
-        expect(material.useGammaTonemap).to.equal(true);
+        expect(material.useTonemap).to.equal(true);
         expect(material.useLighting).to.equal(true);
         expect(material.useMetalness).to.equal(false);
         expect(material.useMetalnessSpecularColor).to.equal(false);
diff --git a/test/test-assets/box/1/Box Material.json b/test/test-assets/box/1/Box Material.json
index d51a80807..cdb7263a0 100644
--- a/test/test-assets/box/1/Box Material.json
+++ b/test/test-assets/box/1/Box Material.json
@@ -142,6 +142,6 @@
   "useFog": true,
   "useLighting": true,
   "useSkybox": true,
-  "useGammaTonemap": true,
+  "useTonemap": true,
   "mapping_format": "path"
 }
diff --git a/test/test-assets/plane/31208636/lambert1.json b/test/test-assets/plane/31208636/lambert1.json
index 624cbc434..25bdba50d 100644
--- a/test/test-assets/plane/31208636/lambert1.json
+++ b/test/test-assets/plane/31208636/lambert1.json
@@ -1,1 +1,1 @@
-{"shader":"blinn","ambient":[0,0,0],"diffuse":[0.4,0.4,0.4],"specular":[0,0,0],"emissive":[0,0,0],"opacity":1,"aoMapChannel":"r","aoMapTiling":[1,1],"aoMapOffset":[0,0],"occludeSpecular":1,"diffuseMapChannel":"rgb","diffuseMapTiling":[1,1],"diffuseMapOffset":[0,0],"specularMapChannel":"rgb","specularMapTiling":[1,1],"specularMapOffset":[0,0],"specularAntialias":true,"metalnessMapChannel":"r","metalnessMapTiling":[1,1],"metalnessMapOffset":[0,0],"metalness":1,"conserveEnergy":true,"glossMapChannel":"r","glossMapTiling":[1,1],"glossMapOffset":[0,0],"emissiveMapChannel":"rgb","emissiveMapTiling":[1,1],"emissiveMapOffset":[0,0],"emissiveIntensity":1,"normalMapTiling":[1,1],"normalMapOffset":[0,0],"bumpMapFactor":1,"heightMapChannel":"r","heightMapTiling":[1,1],"heightMapOffset":[0,0],"heightMapFactor":1,"opacityMapChannel":"r","opacityMapTiling":[1,1],"opacityMapOffset":[0,0],"reflectivity":1,"refractionIndex":0.6666666666666666,"cubeMapProjectionBox":{"center":[0,0,0],"halfExtents":[0.5,0.5,0.5]},"lightMapChannel":"rgb","lightMapUv":1,"lightMapTiling":[1,1],"lightMapOffset":[0,0],"depthTest":true,"depthWrite":true,"cull":1,"blendType":3,"shadowSampleType":1,"useFog":true,"useLighting":true,"useSkybox":true,"useGammaTonemap":true,"mapping_format":"path"}
\ No newline at end of file
+{"shader":"blinn","ambient":[0,0,0],"diffuse":[0.4,0.4,0.4],"specular":[0,0,0],"emissive":[0,0,0],"opacity":1,"aoMapChannel":"r","aoMapTiling":[1,1],"aoMapOffset":[0,0],"occludeSpecular":1,"diffuseMapChannel":"rgb","diffuseMapTiling":[1,1],"diffuseMapOffset":[0,0],"specularMapChannel":"rgb","specularMapTiling":[1,1],"specularMapOffset":[0,0],"specularAntialias":true,"metalnessMapChannel":"r","metalnessMapTiling":[1,1],"metalnessMapOffset":[0,0],"metalness":1,"conserveEnergy":true,"glossMapChannel":"r","glossMapTiling":[1,1],"glossMapOffset":[0,0],"emissiveMapChannel":"rgb","emissiveMapTiling":[1,1],"emissiveMapOffset":[0,0],"emissiveIntensity":1,"normalMapTiling":[1,1],"normalMapOffset":[0,0],"bumpMapFactor":1,"heightMapChannel":"r","heightMapTiling":[1,1],"heightMapOffset":[0,0],"heightMapFactor":1,"opacityMapChannel":"r","opacityMapTiling":[1,1],"opacityMapOffset":[0,0],"reflectivity":1,"refractionIndex":0.6666666666666666,"cubeMapProjectionBox":{"center":[0,0,0],"halfExtents":[0.5,0.5,0.5]},"lightMapChannel":"rgb","lightMapUv":1,"lightMapTiling":[1,1],"lightMapOffset":[0,0],"depthTest":true,"depthWrite":true,"cull":1,"blendType":3,"shadowSampleType":1,"useFog":true,"useLighting":true,"useSkybox":true,"useTonemap":true,"mapping_format":"path"}
\ No newline at end of file
diff --git a/tests/test-assets/box/1/Box Material.json b/tests/test-assets/box/1/Box Material.json
index d51a80807..cdb7263a0 100644
--- a/tests/test-assets/box/1/Box Material.json
+++ b/tests/test-assets/box/1/Box Material.json
@@ -142,6 +142,6 @@
   "useFog": true,
   "useLighting": true,
   "useSkybox": true,
-  "useGammaTonemap": true,
+  "useTonemap": true,
   "mapping_format": "path"
 }
diff --git a/tests/test-assets/plane/31208636/lambert1.json b/tests/test-assets/plane/31208636/lambert1.json
index 624cbc434..25bdba50d 100644
--- a/tests/test-assets/plane/31208636/lambert1.json
+++ b/tests/test-assets/plane/31208636/lambert1.json
@@ -1,1 +1,1 @@
-{"shader":"blinn","ambient":[0,0,0],"diffuse":[0.4,0.4,0.4],"specular":[0,0,0],"emissive":[0,0,0],"opacity":1,"aoMapChannel":"r","aoMapTiling":[1,1],"aoMapOffset":[0,0],"occludeSpecular":1,"diffuseMapChannel":"rgb","diffuseMapTiling":[1,1],"diffuseMapOffset":[0,0],"specularMapChannel":"rgb","specularMapTiling":[1,1],"specularMapOffset":[0,0],"specularAntialias":true,"metalnessMapChannel":"r","metalnessMapTiling":[1,1],"metalnessMapOffset":[0,0],"metalness":1,"conserveEnergy":true,"glossMapChannel":"r","glossMapTiling":[1,1],"glossMapOffset":[0,0],"emissiveMapChannel":"rgb","emissiveMapTiling":[1,1],"emissiveMapOffset":[0,0],"emissiveIntensity":1,"normalMapTiling":[1,1],"normalMapOffset":[0,0],"bumpMapFactor":1,"heightMapChannel":"r","heightMapTiling":[1,1],"heightMapOffset":[0,0],"heightMapFactor":1,"opacityMapChannel":"r","opacityMapTiling":[1,1],"opacityMapOffset":[0,0],"reflectivity":1,"refractionIndex":0.6666666666666666,"cubeMapProjectionBox":{"center":[0,0,0],"halfExtents":[0.5,0.5,0.5]},"lightMapChannel":"rgb","lightMapUv":1,"lightMapTiling":[1,1],"lightMapOffset":[0,0],"depthTest":true,"depthWrite":true,"cull":1,"blendType":3,"shadowSampleType":1,"useFog":true,"useLighting":true,"useSkybox":true,"useGammaTonemap":true,"mapping_format":"path"}
\ No newline at end of file
+{"shader":"blinn","ambient":[0,0,0],"diffuse":[0.4,0.4,0.4],"specular":[0,0,0],"emissive":[0,0,0],"opacity":1,"aoMapChannel":"r","aoMapTiling":[1,1],"aoMapOffset":[0,0],"occludeSpecular":1,"diffuseMapChannel":"rgb","diffuseMapTiling":[1,1],"diffuseMapOffset":[0,0],"specularMapChannel":"rgb","specularMapTiling":[1,1],"specularMapOffset":[0,0],"specularAntialias":true,"metalnessMapChannel":"r","metalnessMapTiling":[1,1],"metalnessMapOffset":[0,0],"metalness":1,"conserveEnergy":true,"glossMapChannel":"r","glossMapTiling":[1,1],"glossMapOffset":[0,0],"emissiveMapChannel":"rgb","emissiveMapTiling":[1,1],"emissiveMapOffset":[0,0],"emissiveIntensity":1,"normalMapTiling":[1,1],"normalMapOffset":[0,0],"bumpMapFactor":1,"heightMapChannel":"r","heightMapTiling":[1,1],"heightMapOffset":[0,0],"heightMapFactor":1,"opacityMapChannel":"r","opacityMapTiling":[1,1],"opacityMapOffset":[0,0],"reflectivity":1,"refractionIndex":0.6666666666666666,"cubeMapProjectionBox":{"center":[0,0,0],"halfExtents":[0.5,0.5,0.5]},"lightMapChannel":"rgb","lightMapUv":1,"lightMapTiling":[1,1],"lightMapOffset":[0,0],"depthTest":true,"depthWrite":true,"cull":1,"blendType":3,"shadowSampleType":1,"useFog":true,"useLighting":true,"useSkybox":true,"useTonemap":true,"mapping_format":"path"}
\ No newline at end of file