调色架构设计
doka is a powerful JavaScript Image Editor that integrates with every stack
####Brightness
Each channel is a floating point number with a range of 0 to 1, where 0 is unlit and 1 is fully lit. So, vec3(0, 0, 0) represents black, vec3(1, 0, 0) is red, vec3(0, 0, 1) makes blue, and vec3(1, 1, 1) is white. To increase or decrease brightness, we can add or subtract a floating point number. To change brightness, we must add or subtract an equal amount of red, green, and blue. Consider following example:
1 | vec3(0, 0, 0) + 0.5 // yields vec3(0.5, 0.5, 0.5), so we went from black to gray |
2 | vec3(1, 0, 0) + 0.5 // yields vec3(1.5, 0.5, 0.5), which is clamped to vec3(1, 0.5, 0.5), which makes a light shade of red |
3 | |
4 | vec3(0, 0, 1) - 0.5 // yields vec3(-0.5, -0.5, 0.5), which is clamped to vec3(0, 0, 0.5), giving us a dark shade of blue |
Let’s do this using the color variable in a function we’ll call adjustBrightness:
1 | vec3 adjustBrightness(vec3 color, float value) { |
2 | return color + value; |
3 | } |
4 | |
5 | color = adjustBrightness(color, 0.5); // yields a lighter colour |
6 | color = adjustBrightness(color, -0.5); // yields a darker colour |
Contrast
When we increase contrast, we want values less than 0.5 to decrease and values greater than 0.5 to increase. We can do this in two ways: write if-statements that handle values less than 0.5 differently than values greater than 0.5. Alternatively, we can do this with linear algebra. In shaders, we usually prefer a mathematical approach over a logical one.
We can leverage the peculiarity of negative numbers. If we multiply both positive and negative numbers by two, they move in different directions. 2 * -0.5 yields -1, and 2 * 0.5 yields 1. We can do this by shifting the 0-1 range of colours to -0.5 to 0.5 (color - 0.5), multiply the colour with our contrast value, and shift it back to the original range (color + 0.5). Maybe this makes more sense in code:
1 | vec3 adjustContrast(vec3 color, float value) { |
2 | return 0.5 + value * (color - 0.5); |
3 | } |
Because we multiply the colour with the contrast value, we can’t use 0 as a base contrast value. Any colour multiplied with 0 becomes black. Instead, our base value must be 1, because 1 * n = n. A value of <1 reduces contrast and a value of >1 increases contrast. We can either choose to take this into account when we pass values to the shader, or in the shader itself. I’ll choose to account for it in the shader, so we can pass a -1 to 1 value.
1 | vec3 adjustContrast(vec3 color, float value) { |
2 | return 0.5 + (1.0 + value) * (color - 0.5); |
3 | } |
4 | |
5 | color = adjustContrast(color, 0.5); // yields a higher contrast |
6 | color = adjustContrast(color, -0.5); // yields a lower contrast |
Exposure
We have arrived at the last effect: exposure. This is similar to brightness, but the change of brightness is proportional to the luminosity of the colours. In other words, we have to multiply instead of add. We have to use 1 as the base value for the exact same reason we had to for contrast.
1 | vec3 adjustExposure(vec3 color, float value) { |
2 | return (1.0 + value) * color; |
3 | } |
4 | |
5 | color = adjustExposure(color, 0.5); // yields a higher exposure |
6 | color = adjustExposure(color, -0.5); // yields a lower exposure |
Saturation
When we adjust saturation, we adjust the contrast between channels. A desaturated image is grayscale, so each channel has equal value (r == g == b). When we increase saturation, the contrast becomes greater. This means a colour (e.g. vec3(0.75, 0.2, 0.1)) that is fully saturated yields a colour with one channel fully lit (vec3(1, 0, 0)).
When we desaturate, we’re essentially moving between a grayscale variant and the original image. We can use the same math to extrapolate the colours, increasing the saturation. WebGL gives us the mix(x, y, t) function, which performs linear interpolation, which we can use for both. If t is less than 1, we’re desaturating. If it’s greater, we’re saturating.
1 | vec3 adjustSaturation(vec3 color, float value) { |
2 | return mix(grayscale, color, 1.0 + value); |
3 | } |
You may have noticed that grayscale isn’t defined yet. We have yet to calculate the colour if a pixel is fully desaturated. The naive way is to take the average of the channels or pick the brightness of a single channel. As human beings, we don’t perceive every red, green, and blue as equally luminous. We have to take that into account. The Web Content Accessibility Guidelines (WCAG) takes that into account and conveniently documented how we can calculate the brightness from an RGB colour in the section relative luminance.
You know what else is convenient? Multiplying each channel with a number and taking the sum is the same as a dot product between two vectors! And GLSL has a built-in function for that! Let’s add the grayscale variable to our adjustSaturation routine:
1 | vec3 adjustSaturation(vec3 color, float value) { |
2 | // https://www.w3.org/TR/WCAG21/#dfn-relative-luminance |
3 | const vec3 luminosityFactor = vec3(0.2126, 0.7152, 0.0722); |
4 | vec3 grayscale = vec3(dot(color, luminosityFactor)); |
5 | |
6 | return mix(grayscale, color, 1.0 + value); |
7 | } |
8 | |
9 | color = adjustSaturation(color, 0.5); // yields a higher saturationP |
10 | color = adjustSaturation(color, -0.5); // yields a lower saturation |
Colour Matrices
2ewwWe can consider a 4-component vector a 1 by 4 matrix. You may also have noticed that our functions for each effect used multiplication and addition to calculate the new colours. Instead of having a formula for each effect, we can combine them into a single colour matrix.
Each row of a colour matrix is the output value of each channel and each column is the input value of each channel. Consider this colour matrix:
1 | r g b a w |
2 | r 1 0 0 0 0 |
3 | g 0 1 0 0 0 |
4 | b 0 0 1 0 0 |
5 | a 0 0 0 1 0 |
If you pay attention, you probably noticed the extra column, marked w for white. We’ll discuss that later. What you’re seeing here is a colour matrix that keeps all original colours. The diagonal line of 1s tells us this is an identity matrix. If we read the row for red, we’ll see all values are nil except for the column red. This means that the output will have the same amount of red as the original–it’s unchanged. Same goes for green, blue, and alpha. Let’s consider another colour matrix:
1 | r g b a w |
2 | r 0 0 1 0 0 |
3 | g 0 1 0 0 0 |
4 | b 1 0 0 0 0 |
5 | a 0 0 0 1 0 |
Pay close attention to where the 1s are. The amount of red in the output will be the same as the amount of blue of the input. The opposite is true for row blue. If we have a picture of a person wearing a red shirt and blue jeans, these colours are swapped. A red apple would become blue. Note that this matrix will affect other colours that contain red or blue. Yellow (red + green) becomes cyan (blue + green), for example. Let’s now have a look at that white column.
1 | r g b a w |
2 | r 0 0 0 0 1 |
3 | g 0 1 0 0 0 |
4 | b 0 0 1 0 0 |
5 | a 0 0 0 1 0 |
The last column allows us to shift the brightness for that channel. In this case, we’ve set the offset for red to 1. This means that pixels that are black in the original would become red in the output. For more reading on colour matrices, check out SVG’s feColorMatrx. Now you know what a colour matrix is, let’s replace the calculations in our shader with a colour matrix!
First, we need to pass the matrices to the shaders. Unfortunately, WebGL shaders only support matrices up to 4x4. We can cut off the white column and send it to the shader separately as a vector. Because this column offsets the brightness of each colour, I decided to name the uniform u_offset instead of u_white.
1 | uniform mat4 u_matrix; // the RGBA matrix |
2 | uniform vec4 u_offset; // the white column |
Calculating the new colour is a matter of matrix multiplication, which you can do with the multiply arithmetic operator (*):
1 | vec4 texel = texture2D(u_map, v_uv); |
2 | gl_FragColor = u_matrix * texel + u_offset; |
At this point, we have two options. We can either multiply all colour matrices in JavaScript and pass a single matrix (and vector) to our shader program, or multiply them in the shader. I’ll pass all matrices to the shader separately so we get the complete picture in a single code sample.
1 | uniform mat4 u_brightnessMatrix; |
2 | uniform vec4 u_brightnessOffset; |
3 | uniform mat4 u_contrastMatrix; |
4 | uniform vec4 u_contrastOffset; |
5 | uniform mat4 u_exposureMatrix; |
6 | uniform vec4 u_exposureOffset; |
7 | uniform mat4 u_saturationMatrix; |
8 | uniform vec4 u_saturationOffset; |
9 | |
10 | void main() { |
11 | vec4 texel = texture2D(u_map, v_uv); |
12 | mat4 matrix = u_brightnessMatrix * u_contrastMatrix * u_exposureMatrix * u_saturationMatrix; |
13 | vec4 offset = u_brightnessOffset + u_contrastOffset + u_exposureOffset + u_saturationOffset; |
14 | |
15 | gl_FragColor = matrix * texel + offset; |
16 | } |
Lastly, we need to define brightness, contrast, exposure, and saturation as matrices. For brightness, we use an identity matrix and set the white column to adjust the brightness. Note that brightness does not adjust the alpha channel.
1 | x = brightness (0-2) |
2 | |
3 | r g b a w |
4 | r 1 0 0 0 x |
5 | g 0 1 0 0 x |
6 | b 0 0 1 0 x |
7 | a 0 0 0 1 0 |
Remember the formula for contrast? We performed multiplication and shifted the range. We can do that with a matrix too:
1 | x = contrast (0-2) |
2 | y = (1 - x) / 2 |
3 | |
4 | r g b a w |
5 | r x 0 0 0 y |
6 | g 0 x 0 0 y |
7 | b 0 0 x 0 y |
8 | a 0 0 0 1 0 |
Exposure is similar to contrast, but without the brightness shift:
1 | x = exposure (0-2) |
2 | |
3 | r g b a w |
4 | r x 0 0 0 0 |
5 | g 0 x 0 0 0 |
6 | b 0 0 x 0 0 |
7 | a 0 0 0 1 0 |
We’re almost there! Let’s finish up with saturation:
1 | // https://www.w3.org/TR/WCAG20/#relativeluminancedef |
2 | lr = 0.2126 |
3 | lg = 0.7152 |
4 | lb = 0.0722 |
5 | |
6 | s = saturation (0-2) |
7 | sr = (1 - s) * lr |
8 | sg = (1 - s) * lg |
9 | sb = (1 - s) * lb |
10 | |
11 | r g b a w |
12 | r sr+s sg sb 0 0 |
13 | g sr sg+s sb 0 0 |
14 | b sr sg sb+s 0 0 |
15 | a 0 0 0 1 0 |
Check out the demo.