Color Mixing in Splat!
Intro
When you first think about mixing colors, it seems like a fairly trivial problem. Thinking logically, you may assume that (ColorA + ColorB) / 2 would yield the mixture of the two colors.
However, as I soon found out, that solution does not quite work as expected. The reason being the color model (RGB) used in computer graphics. For those not versed in the topic, the RGB color model represents colors with three channels. Red, green and blue. The value of each varies between 0 and some upper bound. Thus the combination of differing values for each channel produces a different color.
Here's an example where the upper bound is 255. (24bit color)
R=255, G=0, B=0
R=255, G=0, B=255
R=255, G=255, B=255
The problem
Though simple, this model doesn't represent the mixing of paints well. Some instances work fine, but not so much for others. The way our eyes perceive light and color is better represented with a much different color model known as HSV. However, the conversion from HSV to RGB is trickey. Or at least it was for me.
So without using HSV how do you accurately represent the mixture of colors? Well, that was the million dollar question. For quite some time I tinkered with different solutions to the problem. In fact it went on so long that Garrett, Ryan and I were forced to have a rather heated discussion about how to proceed. Whether we were going to hard-code the color mixture results (yuck). Or calculate them on the fly (yay!).
The solution
It was during that conversation that I remember staring blankly at a color wheel on my laptop's screen. Then it clicked. I could hard-code the 12 main colors on the color wheel and store them in an array, indexed 0 - 11. Next I imagined a pointer on the wheel, pointing to a specific color. The angle of this pointer would be measured in radians (0-2Pi). The value of the angle would then be mapped to one of the 12 colors. Bingo!
C# + XNA 4.0 code for the 12 main colors. Stored in an array.
private static readonly Vector3[] m_spec = {
(new Color(2, 146, 206)).ToVector3(), // Lt Blue: (0 / 12) * 2Pi
(new Color(2, 71, 254)).ToVector3(), // Blue: (1 / 12) * 2Pi
(new Color(61, 1, 164)).ToVector3(), // Purple: (2 / 12) * 2Pi
(new Color(134, 1, 175)).ToVector3(), // Magenta: (3 / 12) * 2Pi
(new Color(167, 25, 75)).ToVector3(), // Maroon: (4 / 12) * 2Pi
(new Color(254, 39, 18)).ToVector3(), // Red: (5 / 12) * 2Pi
(new Color(253, 83, 8)).ToVector3(), //R Orange: (6 / 12) * 2Pi
(new Color(251, 153, 2)).ToVector3(), // Orange: (7 / 12) * 2Pi
(new Color(250, 188, 2)).ToVector3(), //Y Orange: (8 / 12) * 2Pi
(new Color(254, 254, 51)).ToVector3(),// Yellow: (9 / 12) * 2Pi
(new Color(208, 234, 43)).ToVector3(),// Y Green: (10/ 12) * 2Pi
(new Color(102, 176, 50)).ToVector3() // Green: (11/ 12) * 2Pi
};
Here's an example of how each color would approximately map to an angular value.
As you can see from the image above, each color on the color wheel now corresponds to an angular value. For example, blue would be Pi / 2, green would be Pi and red could be either 0 or 2 Pi!
This makes everything very simple, now we can just take two angles (colors) add them together then divide by two this will yield an angle which can in turn be looked up on the color wheel, yay!
You may be thinking to yourself, "Ok, cool... But what happens when that angle lands on the boundary between two colors? What the heck happens then?". Well my friend, that is a good question. Luckily with a little bit of math we can cheat by blending the two neighboring colors on such a boundary.
This can be achieved through a technique known as linear interpolation. The concept is simple. Given two source values (s1, s2), and a weight(w) value between 0-1. A return value (r) can be generated which lies between s1 and s2 dependent on w.
Formula for linear interpolation
r = (s1 * (1 - w)) + (s2 * w)
I've provided the code below which brings all these concepts together. I hope this can help you in your color mixing ventures, Enjoy!
public static Vector3 ColorWheel(float theta)
{
// wrap the Angle if theta > Pi*2
while (theta < 0) theta += PI * 2;
while (theta > PI * 2) theta -= PI * 2;
// Angle between each color
var dt = 2 * PI / m_spec.Length;
var ratio = theta / (2 * PI);
// find the neighboring color indices
var lower = (int)(ratio * m_spec.Length) % m_spec.Length;
var upper = (int)Math.Ceiling(ratio * m_spec.Length) % m_spec.Length;
var tl = lower * dt;
var tu = upper * dt;
// normalize the difference
// for when lower = 11, upper = 0
tu += tu - tl <= 0 ? PI * 2 : 0;
var n = (theta - tl) / dt;
// interpolate the two
var color = (m_spec[lower] * (1 - n)) + (m_spec[upper] * (n));
return color;
}
Note:
This method is a very stripped down implementation of how you could 'truly' represent colors with radians. In order to make this more accurate the addition of a saturation vector, or scalar would be required. Thus the mixing of colors opposite one another on the color wheel will result in black, or grey.
If you have any questions, post a comment or feel free to email me!