@kognifai/cogsengine
    Preparing search index...

    sRGB and linear color

    We will use the term 'linear' a lot here, and we mean that in a mathematical sense with regards to energy, that is, f(x + y) = f(x) + f(y) where x and y are measures of energy, in other words, 'normal math works': If we have have one lamp of x watts and add another lamp of y watts, we end up with x + y watts of power in the scene and not something else.

    Color is actually a distribution of values over the visible spectrum, i.e. a continuous function. However, the human eye has three kinds of color receptors, and the combination of the amount stimulus these three kinds of receptors gets gives the human experience of color. Hence it is usually sufficient to describe a color with three numbers, each number describing the stimulus of a kind of receptor. The precise mapping is defined by the color space.

    The experienced response of the human visual system is not linear with regard to energy: To double the perceived brightness you need to add roughly four times the energy (and not just double the energy which was to be expected if the response was linear).

    The transfer function, the mathematical expression that relates picture signal to light output typically have a parameter with the Greek letter γ (gamma). So the non-linear (in our case perceptual) space typically is called gamma-space along a specific gamma value.

    A coincidence is that the response of the CRT screens (old-school TV/monitors) is roughly the inverse of the response of the visual system. That means that the electric driving signal to such a screen can be perceptual values and the end-experience is roughly correct (double the signal and the perceived brightness doubles).

    Hence, colors were typically specified, stored and computed as perceptual values. This was convenient since no explicit conversion was needed when reading an image and feeding it to a display. Also, storing image data as perceptual values puts more precision in the dark colors, where the eye is more sensitive to small changes. So it can be thought of a compression; we can get away with using less bits per color before the eye notices artifacts.

    Regardless of how convenient and natural a perceptual representation is, using it directly for maths gives the wrong results. Shading, filtering, blending, calculating averages (e.g. downscaling an image) ends up a bit wrong.

    The fix is to linearize the colors values, that is, remove the non-linearity. As mentioned, a doubling of perceived intensity requires roughly a four-fold increase in energy. But if we square the numbers before adding them and taking the square root, addition works. And this is actually just representing the a color specified with perceptual values in linear values, doing math, and representing the result as a perceptual value. By defining color spaces precisely, this mapping gets more precise.

    The sRGB color space defines the chromaticies of primary colors (that is, which physical color is e.g. 100% red) and a transfer function (how is a value in this space to be represented physically). It tries to match computer monitors used in the late 90s, that is, codifies "current practice". It has become the default color space when nothing else is specified.

    A color is converted from sRGB to linear space with the expression:

               +-
               |   (1/12.92) C_srgb                if C_srgb <= 0.04045
    C_linear = {
               |   ((C_srgb + 0.055)/1.055)^2.4    if 0.04045 < C_srgb
               +-
    

    A common approximation is

    C_linear = C_srgb ^ 2.2
    

    For max performance, there is this approximation

    C_linear = C_srgb ^ 2 = C_srgb * C_srgb
    

    To convert a color from linear color space to sRGB, the definition is:

             +-
             |    12.92 * C_linear                      C_linear <= 0.0031308
    C_srgb = {
             |    1.055 * C_linear^(1/2.4) - 0.055,     0.0031308 < C_linear
             +-
    

    A common approximation is

    C_srgb = C_linear ^ (1/2.2)
    

    For max performance, there is this approximation

    C_srgb = sqrt( C_linear )
    

    Hardware support for sRGB

    Most graphics hardware has native support for converting colors between sRGB and linear, and this is faster than rolling your own, and for textures, this is done handled before texture sampler blends the texture values that are passed to the shader.

    The support is exposed via pixel formats with sRGB in their name:

    • An sRGB pixel format stores its data as sRGB (which is good as it puts the precision in the darks where it is most needed).
    • The color data provided by the user is expected to be sRGB colors. Thus, no conversions needed for png or jpg data.
    • The color data is converted to linear colors in the texture samplers before blending and passing the result to a shader. The shader gets a linear color.

    So: for any texture that contains color, use an sRGB texture format. For textures that contain numeric data (like normals or ids), use a non-sRGB format.

    sRGB framebuffers

    On desktop graphics API, an sRGB pixel format implies that the output is presented as sRGB, the output of the shader is assumed to be linear colors and the hardware will do the conversion.

    A web canvas expects the contents to be sRGB, but will not do the conversion. However, writing to an offscreen texture with sRGB pixel format backing will have the conversion applied. So the default render target on WebGL is a bit special.

    Cogs and sRGB

    Cogs does all shading calculations in linear color space:

    • Client code should provide sRGB texture formats for textures with color data. Then everything should work automatically.

    • If a material property is declared as sRGB, sRGB to linear conversion is automatically applied when setting the value. Example:

    { 
    "Material": {
    "properties": {
    "FooBarParameters": {
    "someColor": "float4 srgb"
    }
    }
    }
    }
    • For desktop backends, Cogs expects an sRGB capable framebuffer and outputs linear colors. For ES3 backends (used for WebGL), the required conversion is added. This can be overridden with the renderer.backBuffer.sRGB variable, but should usually be not necessary. The conversion looks like (from ForwardPS.es30.glsl):
      if((sceneFlags & COGS_SCENEFLAGS_OUTPUT_SRGB) != 0u) {
        color.rgb = clamp(color.rgb, vec3(0.0), vec3(1.0));
        color.rgb = mix(1.055 * pow(color.rgb, vec3(1.0/2.4)) - vec3(0.055),
                        12.92 * color.rgb,
                        lessThan(color.rgb, vec3(0.0031308)));
      }
      fragColor = color;
    

    Troubleshooting

    You can use the fact that turning every-other pixel (checkerboard) max white and black should have 50% of the energy of pure white, that is, should be the color of 0.5 in linear space or 0.74 = 0xbb in sRGB.

    If you add the following snippet to the end of ForwardPS.es30.glsl:

      ...
      color.rgb = mix(1.055 * pow(color.rgb, vec3(1.0/2.4)) - vec3(0.055),
                      12.92 * color.rgb,
                      lessThan(color.rgb, vec3(0.0031308)));
    }
    fragColor = color;
    
    // Checkerboard on the left 200 pixels
    if(gl_FragCoord.x < 200.0) {
      if((int(gl_FragCoord.x) & 1) != (int(gl_FragCoord.y) & 1)) {
        fragColor = vec4(1,1,1,1);
      }
      else {
        fragColor = vec4(0,0,0,1);
      }
    }
    

    all output colors on the left 200 pixels should be in such a checkerboard. Any color you provide as sRGB (0xbbbbbb or 0.74,0.74,0.74) or as linear(0.5, 0.5, 0.5) should match the brightness of the checkerboard. You might need to squint your eyes a bit to blend the checker tiles into gray in your eyes.