Skip to content
Fundamental · Color mathBeginner

RGB(A), luminance & blend modes

Color as a vec3 or vec4, what alpha means, how bright a color looks, and the math behind multiply, screen, overlay and add.

How it works

To a shader a color is a vector: red, green and blue in a vec3, from 0 (none) to 1 (full), plus an optional fourth value, alpha, in a vec4. Everything from vector basics applies. Arithmetic works per channel, .rgb and .a are swizzles, and mix blends two colors as easily as two numbers.

Arithmetic as light

The basic operations each have a physical reading, which makes them easy to choose:

vec3 base  = vec3(0.8, 0.6, 0.4);
vec3 tint  = base * vec3(1.0, 0.5, 0.5); // multiply: a colored filter; only darkens
vec3 lit   = base + vec3(0.2);           // add: more light on top; only brightens
vec3 dim   = base * 0.5;                 // scale: the same color, half the light
vec3 blend = mix(base, vec3(0.1, 0.2, 0.9), 0.3); // 30% of the way toward blue

Multiplying by white (vec3(1.0)) changes nothing and by black gives black, which is why textures and lighting are combined by multiplying. Values above 1 are fine inside a shader, as bright light that hasn’t been mapped to the screen yet; the output clamps them to 1 unless it is rendering to a floating-point target. Negative values have no meaning as color, and usually come from a subtraction or a sharpen that needs a max(c, 0.0).

Alpha

Alpha is the fourth channel. By convention it means coverage or opacity: 1 is solid, 0 is invisible. The shader only outputs it; whether it does anything depends on blending, which is set outside the shader. With blending off, alpha does not mix this draw with what is already there; it is simply stored in the target. Most targets ignore it, but a WebGL canvas with the default alpha: true uses it to composite over the page, so write 1.0 there unless you want the page to show through.

There are two ways to store a color with alpha. Straight alpha keeps the color as it is, and opacity separately. Premultiplied alpha stores the color already multiplied by its alpha, so a half-transparent red is vec4(0.5, 0.0, 0.0, 0.5). Layering a color over a background (“over”) reads:

vec3 overStraight = src.rgb * src.a + dst.rgb * (1.0 - src.a); // straight alpha
vec3 overPremul   = src.rgb +         dst.rgb * (1.0 - src.a); // premultiplied

Premultiplied colors filter and blur correctly (a fully transparent texel contributes nothing, whatever color it stores) and can express additive glow (alpha 0 with color above 0). Mixing the two conventions by accident gives dark or bright fringes around soft edges.

Luminance

The three channels don’t look equally bright. Eyes are most sensitive to green and least to blue, so full green looks far brighter than full blue. Luminance weights the channels by that sensitivity; for the sRGB and Rec. 709 primaries used by screens the weights are:

float luminance(vec3 c) { return dot(c, vec3(0.2126, 0.7152, 0.0722)); }

The weights sum to 1, so white stays 1. Luminance is the right way to make a color gray (vec3(luminance(c))), to adjust saturation (mix(vec3(luminance(c)), c, amount): 0 is gray, 1 the original, above 1 more vivid), to pick bright areas for a glow, or to run an edge detector on one channel instead of three. These weights are meant for linear values; the next page, on gamma, explains the difference.

Blend modes

Image editors name a handful of ways to combine a base color a with a layer b. Each is a line of channel-wise math on values from 0 to 1:

  • Multiply a * b: darkens, like stacking two slides. White is neutral.
  • Screen 1.0 - (1.0 - a) * (1.0 - b): brightens, the inverse of multiply. Black is neutral.
  • Add a + b: brightens and can pass 1; glows, fire and light are added.
  • Overlay: multiply where the base is dark, screen where it is light, which raises contrast. 50% gray is neutral.

Overlay has a branch per channel, which step and mix write without an if:

vec3 overlay(vec3 a, vec3 b) {
    vec3 dark  = 2.0 * a * b;                        // multiply, doubled
    vec3 light = 1.0 - 2.0 * (1.0 - a) * (1.0 - b);  // screen, doubled
    return mix(dark, light, step(0.5, a));          // pick per channel by the base
}

To apply any mode at partial strength, mix from the base to the result: mix(a, overlay(a, b), opacity).

The colors in this site’s cards follow the same math. A color you set in a card’s controls is written as you see it and arrives in the shader already converted to linear values, which is what makes the luminance weights and the light arithmetic above hold.

Further reading