Repository: playcanvas/engine

Issue:
Vertex color issue
Imported GLB doesn't show vertex colors correctly. Interestingly, FBX seems to be correct.

Repro: https://playcanvas.com/project/1284416/overview/vertex-color-issue

Same cube model, left FBX, right GLB.

![Image](https://github.com/user-attachments/assets/eb779e25-51c9-43b2-898d-d1de17ec04a8)

Wasn't sure if it fits into any existing issues, or if its an Editor issue even. Feel free to reassign.


Discussion:
does the original glb load as expected in https://playcanvas.com/viewer ? That likely decides if this is an engine issue, or glb import issue in the Editor.
The viewer is also showing wrong colors. It is not as apparent with just a colored box, as it is hard to say if the color is what it should be, but we can see it with our end models.
Added another variant of a cube, with interpolated colors. FBX vs GLB.

![Image](https://github.com/user-attachments/assets/a2dffd20-73c2-4eef-ad1f-72a3f8f55501)
This seems like srgb difference - the cubes on top anyways. I'll investigate, thanks
As you confirmed @LeXXik , gltf specifies vertex colors as linear:
https://github.com/KhronosGroup/glTF/pull/1729

but the engine assumes they're in gamma space and converts them to linear, but when used on albedo or emissive channel.

We could change the engine to assume those are linear. Not sure if the import pipeline would need to get some changes.
But this would change the look of the game of existing projects, which is not ideal.

We could add a material option (vertexColorGamma or similar), and maybe enabled this for glb loading. So then only the procedurally created meshes would by default use colors in sRGB.
Moved to engine, as that's where the fix would need to be based.
Some update here? 



Patch:
diff --git a/src/scene/materials/standard-material-options-builder.js b/src/scene/materials/standard-material-options-builder.js
index cad1503cb3d..09c220195e9 100644
--- a/src/scene/materials/standard-material-options-builder.js
+++ b/src/scene/materials/standard-material-options-builder.js
@@ -289,6 +289,8 @@ class StandardMaterialOptionsBuilder {
         options.litOptions.useDynamicRefraction = stdMat.useDynamicRefraction;
         options.litOptions.dispersion = stdMat.dispersion > 0;
         options.litOptions.shadowCatcher = stdMat.shadowCatcher;
+
+        options.litOptions.useVertexColorGamma = stdMat.vertexColorGamma;
     }
 
     _updateEnvOptions(options, stdMat, scene, cameraShaderParams) {
diff --git a/src/scene/materials/standard-material-options.js b/src/scene/materials/standard-material-options.js
index c3cc50909cb..36953643252 100644
--- a/src/scene/materials/standard-material-options.js
+++ b/src/scene/materials/standard-material-options.js
@@ -47,6 +47,8 @@ class StandardMaterialOptions {
 
     lightMapEncoding = 'linear';
 
+    vertexColorGamma = false;
+
     /**
      * If normal map contains X in RGB, Y in Alpha, and Z must be reconstructed.
      *
diff --git a/src/scene/materials/standard-material-parameters.js b/src/scene/materials/standard-material-parameters.js
index fb8f059fb6b..9c41de2247e 100644
--- a/src/scene/materials/standard-material-parameters.js
+++ b/src/scene/materials/standard-material-parameters.js
@@ -33,6 +33,8 @@ const standardMaterialParameterTypes = {
     ..._textureParameter('diffuseDetail', true, false),
     diffuseDetailMode: 'string',
 
+    vertexColorGamma: 'boolean',
+
     specular: 'rgb',
     specularTint: 'boolean',
     ..._textureParameter('specular'),
diff --git a/src/scene/materials/standard-material.js b/src/scene/materials/standard-material.js
index d563a3415d9..c884c816812 100644
--- a/src/scene/materials/standard-material.js
+++ b/src/scene/materials/standard-material.js
@@ -523,6 +523,11 @@ const _tempColor = new Color();
  * backfaces.
  * @property {boolean} shadowCatcher When enabled, the material will output accumulated directional
  * shadow value in linear space as the color.
+ * @property {boolean} vertexColorGamma When set to true, the vertex shader converts vertex colors
+ * from gamma to linear space to ensure correct interpolation in the fragment shader. This flag is
+ * provided for backwards compatibility, allowing users to mark their materials to handle vertex
+ * colors in gamma space. Defaults to false, which indicates that vertex colors are stored in
+ * linear space.
  *
  * @category Graphics
  */
@@ -1194,6 +1199,7 @@ function _defineMaterialProps() {
     _defineFlag('opacityDither', DITHER_NONE);
     _defineFlag('opacityShadowDither', DITHER_NONE);
     _defineFlag('shadowCatcher', false);
+    _defineFlag('vertexColorGamma', false);
 
     _defineTex2D('diffuse');
     _defineTex2D('specular');
diff --git a/src/scene/shader-lib/glsl/chunks/lit/vert/litMain.js b/src/scene/shader-lib/glsl/chunks/lit/vert/litMain.js
index 6b6220f824e..06aced88854 100644
--- a/src/scene/shader-lib/glsl/chunks/lit/vert/litMain.js
+++ b/src/scene/shader-lib/glsl/chunks/lit/vert/litMain.js
@@ -63,6 +63,15 @@ mat4 dModelMatrix;
 
 #include  "litUserCodeVS"
 
+#ifdef VERTEX_COLOR
+    vec3 decodeGamma(vec3 raw) {
+        return pow(raw, vec3(2.2));
+    }
+    vec4 gammaCorrectInput(vec4 color) {
+        return vec4(decodeGamma(color.xyz), color.w);
+    }
+#endif
+
 void main(void) {
 
     #include "litUserMainStartVS"
@@ -102,7 +111,11 @@ void main(void) {
     #include "uvTransformVS, UV_TRANSFORMS_COUNT"
 
     #ifdef VERTEX_COLOR
-        vVertexColor = vertex_color;
+        #ifdef STD_VERTEX_COLOR_GAMMA
+            vVertexColor = gammaCorrectInput(vertex_color);
+        #else
+            vVertexColor = vertex_color;
+        #endif
     #endif
 
     #ifdef LINEAR_DEPTH
diff --git a/src/scene/shader-lib/glsl/chunks/standard/frag/diffuse.js b/src/scene/shader-lib/glsl/chunks/standard/frag/diffuse.js
index 0cbf75eb71b..46252da5809 100644
--- a/src/scene/shader-lib/glsl/chunks/standard/frag/diffuse.js
+++ b/src/scene/shader-lib/glsl/chunks/standard/frag/diffuse.js
@@ -20,7 +20,7 @@ void getAlbedo() {
     #endif
 
     #ifdef STD_DIFFUSE_VERTEX
-        dAlbedo *= gammaCorrectInput(saturate(vVertexColor.{STD_DIFFUSE_VERTEX_CHANNEL}));
+        dAlbedo *= saturate(vVertexColor.{STD_DIFFUSE_VERTEX_CHANNEL});
     #endif
 }
 `;
diff --git a/src/scene/shader-lib/glsl/chunks/standard/frag/emissive.js b/src/scene/shader-lib/glsl/chunks/standard/frag/emissive.js
index bf1fb5e8e96..09c8afa2112 100644
--- a/src/scene/shader-lib/glsl/chunks/standard/frag/emissive.js
+++ b/src/scene/shader-lib/glsl/chunks/standard/frag/emissive.js
@@ -10,7 +10,7 @@ void getEmission() {
     #endif
 
     #ifdef STD_EMISSIVE_VERTEX
-    dEmission *= gammaCorrectInput(saturate(vVertexColor.{STD_EMISSIVE_VERTEX_CHANNEL}));
+    dEmission *= saturate(vVertexColor.{STD_EMISSIVE_VERTEX_CHANNEL});
     #endif
 }
 `;
diff --git a/src/scene/shader-lib/programs/lit-shader-options.js b/src/scene/shader-lib/programs/lit-shader-options.js
index 0e9ce905f65..a9b41390d30 100644
--- a/src/scene/shader-lib/programs/lit-shader-options.js
+++ b/src/scene/shader-lib/programs/lit-shader-options.js
@@ -83,6 +83,8 @@ class LitShaderOptions {
 
     vertexColors = false;
 
+    useVertexColorGamma = false;
+
     lightMapEnabled = false;
 
     dirLightMapEnabled = false;
diff --git a/src/scene/shader-lib/programs/lit-shader.js b/src/scene/shader-lib/programs/lit-shader.js
index cf0e07259c1..0c8f46de1ac 100644
--- a/src/scene/shader-lib/programs/lit-shader.js
+++ b/src/scene/shader-lib/programs/lit-shader.js
@@ -284,6 +284,9 @@ class LitShader {
             attributes.vertex_color = SEMANTIC_COLOR;
             vDefines.set('VERTEX_COLOR', true);
             varyings.set('vVertexColor', 'vec4');
+            if (options.useVertexColorGamma) {
+                vDefines.set('STD_VERTEX_COLOR_GAMMA', '');
+            }
         }
 
         if (options.useMsdf && options.msdfTextAttribute) {
diff --git a/src/scene/shader-lib/programs/standard.js b/src/scene/shader-lib/programs/standard.js
index 745b2f0a07d..a4a6a70cbef 100644
--- a/src/scene/shader-lib/programs/standard.js
+++ b/src/scene/shader-lib/programs/standard.js
@@ -88,11 +88,8 @@ class ShaderGeneratorStandard extends ShaderGenerator {
         return expression;
     }
 
-    _validateMapChunk(propName, chunkName, chunks) {
+    _validateMapChunk(code, propName, chunkName, chunks) {
         Debug.call(() => {
-            // strip comments from the chunk
-            const code = chunks.get(chunkName).replace(/\/\*[\s\S]*?\*\/|([^\\:]|^)\/\/.*$/gm, '$1');
-
             const requiredChangeStrings = [];
 
             // Helper function to add a formatted change string if the old syntax is found
@@ -170,7 +167,16 @@ class ShaderGeneratorStandard extends ShaderGenerator {
         // log errors if the chunk format is deprecated (format changed in engine 2.7)
         Debug.call(() => {
             if (chunkCode) {
-                this._validateMapChunk(propNameCaps, chunkName, chunks);
+                // strip comments from the chunk
+                const code = chunks.get(chunkName).replace(/\/\*[\s\S]*?\*\/|([^\\:]|^)\/\/.*$/gm, '$1');
+
+                this._validateMapChunk(code, propNameCaps, chunkName, chunks);
+
+                if (vertexColorOption) {
+                    if (code.includes('gammaCorrectInputVec3(saturate3(vVertexColor.') || code.includes('gammaCorrectInput(saturate(vVertexColor.')) {
+                        Debug.errorOnce(`Shader chunk ${chunkName} contains gamma correction code which is incompatible with vertexColorGamma=true. Please remove gamma correction calls from the chunk.`, { code: code });
+                    }
+                }
             }
         });
 
diff --git a/src/scene/shader-lib/wgsl/chunks/lit/vert/litMain.js b/src/scene/shader-lib/wgsl/chunks/lit/vert/litMain.js
index 0b9d8c30e33..96d209e0c71 100644
--- a/src/scene/shader-lib/wgsl/chunks/lit/vert/litMain.js
+++ b/src/scene/shader-lib/wgsl/chunks/lit/vert/litMain.js
@@ -66,6 +66,15 @@ var<private> dModelMatrix: mat4x4f;
 
 #include  "litUserCodeVS"
 
+#ifdef VERTEX_COLOR
+    fn decodeGamma3(raw: vec3f) -> vec3f {
+        return pow(raw, vec3f(2.2));
+    }
+    fn gammaCorrectInputVec4(color: vec4f) -> vec4f {
+        return vec4f(decodeGamma3(color.xyz), color.w);
+    }
+#endif
+
 @vertex
 fn vertexMain(input : VertexInput) -> VertexOutput {
 
@@ -104,7 +113,11 @@ fn vertexMain(input : VertexInput) -> VertexOutput {
     #include "uvTransformVS, UV_TRANSFORMS_COUNT"
 
     #ifdef VERTEX_COLOR
-        output.vVertexColor = vertex_color;
+        #ifdef STD_VERTEX_COLOR_GAMMA
+            output.vVertexColor = gammaCorrectInputVec4(vertex_color);
+        #else
+            output.vVertexColor = vertex_color;
+        #endif
     #endif
 
     #ifdef LINEAR_DEPTH
diff --git a/src/scene/shader-lib/wgsl/chunks/standard/frag/diffuse.js b/src/scene/shader-lib/wgsl/chunks/standard/frag/diffuse.js
index 95174110c36..efe697aacf2 100644
--- a/src/scene/shader-lib/wgsl/chunks/standard/frag/diffuse.js
+++ b/src/scene/shader-lib/wgsl/chunks/standard/frag/diffuse.js
@@ -20,7 +20,7 @@ fn getAlbedo() {
     #endif
 
     #ifdef STD_DIFFUSE_VERTEX
-        dAlbedo = dAlbedo * gammaCorrectInputVec3(saturate3(vVertexColor.{STD_DIFFUSE_VERTEX_CHANNEL}));
+        dAlbedo = dAlbedo * saturate3(vVertexColor.{STD_DIFFUSE_VERTEX_CHANNEL});
     #endif
 }
 `;
diff --git a/src/scene/shader-lib/wgsl/chunks/standard/frag/emissive.js b/src/scene/shader-lib/wgsl/chunks/standard/frag/emissive.js
index c6237aac4dc..461e8f8d663 100644
--- a/src/scene/shader-lib/wgsl/chunks/standard/frag/emissive.js
+++ b/src/scene/shader-lib/wgsl/chunks/standard/frag/emissive.js
@@ -10,7 +10,7 @@ fn getEmission() {
     #endif
 
     #ifdef STD_EMISSIVE_VERTEX
-    dEmission = dEmission * gammaCorrectInputVec3(saturate3(vVertexColor.{STD_EMISSIVE_VERTEX_CHANNEL}));
+    dEmission = dEmission * saturate3(vVertexColor.{STD_EMISSIVE_VERTEX_CHANNEL});
     #endif
 }
 `;


Test patch:
diff --git a/test/scene/materials/standard-material.test.mjs b/test/scene/materials/standard-material.test.mjs
index 4f8ae5670be..9e64209f974 100644
--- a/test/scene/materials/standard-material.test.mjs
+++ b/test/scene/materials/standard-material.test.mjs
@@ -291,6 +291,7 @@ describe('StandardMaterial', function () {
         expect(material.useMetalness).to.equal(false);
         expect(material.useMetalnessSpecularColor).to.equal(false);
         expect(material.useSkybox).to.equal(true);
+        expect(material.vertexColorGamma).to.equal(false);
     }
 
     describe('#constructor()', function () {