# Web Colors, sRGB, and WebGL ## Linearity Colors on the web are treated as perceptually linear. 50% gray appears half as bright as 100% white. However, if you interleave white and black rows of pixels, it *looks* like [~70% gray, not 50%](https://jsfiddle.net/pg7n6ztb/). This demonstrates that having only 50% of the photons still looks 70% as bright. *Keep this in mind if you are resizing anything!* But the web deals in *perceptual* values, not physical photon counts. ## sRGB texture formats are an encoding, not a color space The human eye is much better at seeing small differences between dark values, but not so much for small differences between bright values. In other words, going from 5% to 15% is obvious, whereas 85% to 95% is more subtle. EXT_sRGB is for improving the fidelity of dark "low-tones", while sacrificing precision of bright "high-tones". About 70% of the shades representable by sRGB are darker than 50% gray. sRGB encoding is an 8-bit HDR compression, though still `[0,1]`. Generally in order to do math on colors, we want to operate in linear (preceptual) space again, so that's what your shader gets when you sample from an sRGB texture format. A texture value of 70% is decoded into a ~50% preceptual value as part of sampling. ## Partial support in WebGL WebGL 2 supports sRGB formats for textures and framebuffers. (in WebGL 1, you need EXT_sRGB) However, there's no way to explicitly ask for an sRGB backbuffer to preserve high-quality blacks. To support sRGB-accurate output from WebGL, the browser compositor would also need to render into sRGB surfaces, and Firefox does not do this. (Safari might, Chrome might) It's likely easy to get WebXR to use sRGB backbuffers as well, since it uses a different presentation path. ## NB: Ignore "Monitor Gamma" Really really don't worry about this. It was to deal with the non-linearity of CRTs, but we're stuck with emulating it forever as a legacy to CRTs. It's largely a coincidence that monitor gamma is close to sRGB gamma. # What is GL_SRGB? sRGB texture formats are a storage format, not a different color space. It's for squeezing more low-light precision into 8bit. 0.5f has the same value but different storage representations: * linear normalized unsigned 8-bit (RGBA8): * 0x7f (0.4980) * 0x80 (0.5020) * float32: 0x3f000000 (exact) * sRGB: * 0xbb (0.4969) * 0xbc (0.5029) ``` function linear_to_srgb(x) { if (x <= 0) return 0; if (x >= 1) return 1; if (x < 0.0031308) return 12.92 * x; return 1.055 * Math.pow(x, 1 / 2.4) - 0.055; } function srgb_to_linear(x) { if (x <= 0.04045) return x / 12.92; return Math.pow((x + 0.055) / 1.055, 2.4); } ``` # Should we use sRGB format textures? Always prefer sRGB encoded formats for 8bit color, but **only** if your whole presentation chain use sRGB encoded formats all the way to the display! # Appendix ## A C++ example (live for now: https://repl.it/repls/ProfitableLovingBlock) ``` #include <cmath> #include <cstdint> #include <cstdio> class unorm8_t final { public: uint8_t mBits = 0; static uint8_t ToBits(const float x) { if (x <= 0.0) return 0x00; if (x >= 1.0) return 0xff; return static_cast<uint8_t>(x * 255.0 + 0.5); } static float FromBits(const uint8_t x) { return static_cast<float>(x) / 255.0; } unorm8_t() = default; explicit unorm8_t(const unorm8_t& rhs) : mBits(rhs.mBits) {} unorm8_t(const float rhs) : mBits(ToBits(rhs)) {} operator float() const { return FromBits(mBits); } }; class srgb8_t final { public: uint8_t mBits; static uint8_t ToBits(const float x) { const unorm8_t val = [&]() -> unorm8_t { if (x <= 0.0) return 0.0; if (x >= 1.0) return 1.0; if (x < 0.0031308) return 12.92 * x; return 1.055 *pow(x, 1 / 2.4) - 0.055; }(); return val.mBits; } static float FromBits(const uint8_t bits) { unorm8_t val; val.mBits = bits; const auto x = static_cast<float>(val); if (x < 0.0031308) return 12.92 * x; return pow((x + 0.055) / 1.055, 2.4); } explicit srgb8_t(const srgb8_t& rhs) : mBits(rhs.mBits) {} srgb8_t(const float rhs) : mBits(ToBits(rhs)) {} operator float() const { return FromBits(mBits); } }; int main() { srgb8_t foo = 0.5; printf("srgb8_t(0.5): %f (0x%02x)\n", float(foo), int(foo.mBits)); const auto bitcasted = *(uint8_t*)&foo; printf(" bitcasted: 0x%02x\n", int(bitcasted)); const srgb8_t bar = foo + 0.25; printf("srgb8_t(0.5)+0.25: %f (0x%02x)\n", float(bar), int(bar.mBits)); return 0; } ``` ## Useful links * https://www.colour-science.org/posts/the-importance-of-terminology-and-srgb-uncertainty/