RGB kind of sucks.
RGB, not unlike ASCII, memory addresses, and having 86,400 seconds in day, is one of those things that makes programming a little simpler for a bit, until it doesn’t anymore.
In theory, RGB is a group of color spaces that lets you tell the display how much voltage each subpixel needs. However, in practice, we now have phones with displays that let you show more than 100% red, which is a new type of red called super red. We have other displays that have twice as much blue as red or green. Your RGB values are not corresponding to display voltages and they probably haven’t for a while now.
RGB is also hard to think about. Red, green, and blue additive light don’t behave like much that we’re used to — you can see the individual colors up close but as you get further away, they blend together and you start to see only one color. From far enough away, you can’t convince your mind that there are three lights. You’re currently looking at millions of tiny little 3 light arrays, and yet the effect is so totalizing that you almost never think about it.
Finally, RGB is hard to manipulate. If you start from black, you can increase the amount of “red” in an RGB color picker, which will make things more red. So far so good. Then you start increasing the “green”, and you get…yellow? This is not a very intuitive color space to navigate around. There are other representations of colors that lend themselves to being changed more easily.
Colors for Years
I have a personal app where I need to show a graph of some years. Each year needs a different color on the graph, and so every new year I go into the code, find a nice new color for the new year, and deploy the app. How many years am I going to do this for until I find an algorithm with which to automate it?
I need some colors that are a) arbitrary feeling, b) nice looking, and c) determined purely by an integer for the year. We need to implement a function like this:
func color(for year: Int) -> Color
RGB can really only satisfy the first of my criteria — it can make random colors with random numbers:
Color(red: .random(in: 0..<1), blue: .random(in: 0..<1), green: .random(in: 0..<1))
Unfortunately, colors generated like this look really bad. They often come out muddy and ruddy, and generating more than one color doesn’t come with any pattern or structure. The colors are all over the place.
This is a structural problem with RGB. RGB is focused on how color is produced, rather than how it’s perceived.
Fortunately, the solution to this problem is well documented. There are a few blog posts out there (warning: JavaScript) that lay out an approach. The idea is this: by using a hue based color space, like HSL, you can hold two parameters constant (saturation and lightness), and modify only the hue, giving you multiple colors that live in the same “family”.
(There are subtle differences between HSL, HSB, HSV, and HWB, but the hue rotation is basically the same in all of the color models, and any of them will work well with this technique.)
For example, using 0.8 for both saturation and lightness gives you nice pastels:
Color(hue: .random(in: 0..<360), saturation: 0.8, lightness: 0.8)
You can play with this color picker; drag the “hue” slider to see lots of colors in this family.
On the other hand, 0.6 for the saturation and 0.5 for the lightness gives you more robust colors:
Color(hue: .random(in: 0..<360), saturation: 0.6, lightness: 0.5)
This color picker shows examples of these colors.
Astute readers will note that, while Apple’s own APIs take a number from 0 to 1, this fake initializer I made expects a hue from 0 to 360. I find this to be more illustrative, because this value represents some number of degrees. There’s a physical analogy here to a hue circle. Circles loop back on themselves, and therefore 359º is basically the same color as 1º. This lets you overshoot the end of the hue circle and mod by 360º to get back to a reasonable color.
This lets us implement most of our color(for year: Int)
function.
func color(for year: Int) -> Color {
let spacing = ...
return Color(hue: (year * spacing) % 360, saturation: 0.8, lightness: 0.5)
}
The spacing represents the number of degrees to go around the hue wheel each time we need to pick the next color.
What is the optimal number to chose here?
Rotating in Hue Space
If we make this angle too close to zero, the colors will be too close together on the hue wheel, making them too similar. However, if we make it too close to 360º (a full revolution), once the degrees are modded by 360, they’ll still be too similar, except they’ll go backwards around the hue wheel. Maybe we want to try 180º? That makes every other color the exact same, so that’s not quite right either.
In fact, any rotation that divides evenly into 360º will result in repeats after a while. And 360 has a lot of factors!
One solution is to space things out by the 360 divided by the number of years we have, but then the colors would change every time there’s a new year. It makes a rainbow, which, while it does look nice, doesn’t quite have the random look I’m going for.
However, there’s a better way to do this, and the answer is in a YouTube video I watched over 10 years ago. The remarkable Vi Hart published a series of videos (one, two, three) about how plants need to grow their new leaves in such a way that they won’t be blocked by the leaves above, which lets them receive maximum sunlight. The second video in the series is where the relevant bit is.
The number of degrees around the stalk that a plant decides to grow its next leaf from is the exact number we are looking for: some fraction of a turn to rotate by which will give us non-overlapping leaves — I mean, colors.
Because any rational number will result in repeat colors — or overlapping leaves — she seeks an irrational number; ideally the “most” irrational number. She finds it in ϕ, or roughly 1.618. We want to go 1/1.618th of the hue circle each time we need a new color, and this will give us the colors we want.
func color(for year: Int) -> Color {
let spacing = 360/1.618
return Color(hue: (year * spacing) % 360, saturation: 0.8, lightness: 0.5)
}
If the colors are not to your liking, you can add a little extra rotation by adding a phase shift to the equation:
func color(for year: Int) -> Color {
let spacing = 360/1.618
return Color(hue: 300 + (year * spacing) % 360, saturation: 0.8, lightness: 0.5)
}
This function meets our criteria: colors that come out of it a) are arbitrary, b) look pretty good, and c) are purely determined by the year.
A Step Further
If your only goal is some simple colors for a prototype or for a side project, what I’ve covered so far will suffice. But if you want to use this in more serious and wide-ranging applications, you can take one more step.
HSL has some some serious drawbacks. It, like RGB, was designed for ease of computation rather than precision in the underlying colors. Specifically, when rotating the hue value (which is what we’re doing with this technique), you’ll find that some hues are tinted much lighter than others, even holding saturation and lightness constant. These colors look lighter, even though they’re technically the same “lightness”.
The LCh color space (luminance, chroma, hue) solves this problem. As far as I can tell, it’s the gold standard for colors on a display. It gives you perceptual uniformity, which lets you rotate the hue and get colors that are even more similar to each other than you’d get with HSL; it also confers some benefits when it comes to contrast for reading text.
In fact, if you look closely at the colors above (which represent the colors for the years 2015–2023 using our algorithm), that lime green is looking a little muted relative to its purple neighbor.
You can play with an LCh color picker here. To make LCh work with UIColor, you can use these four useful gists.
Using LCh to generate my colors with the hue rotation technique above yielded beautiful colors.
func color(for year: Int) -> Color {
let spacing = 360/1.618
return Color(luminance: 0.7, chroma: 120, hue: 300 + (year * spacing) % 360)
}
These colors all have similar lightness to me, and they look great for something totally procedurally generated. They’re vibrant, uniform, and wonderful.
The model you choose to inhabit creates constraints that you may not have intended to be constrained by. Any color from any of these color spaces can be (more or less) translated to any other color space with a little bit of math, so the colors we ended up with could be written in terms of red, green, and blue values (again, hand-waving a little here). But while RGB can represent these colors, that doesn’t mean you can easily move through the space in a way that yields colors that look good together. Picking the right color space to start out makes the problem at least tractable.
Tractable, but still not solved. These arbitrary beautiful colors can be generated using a process stochastically discovered by evolution, discovered by scientists in 1830, and brought to practice using a robust set of web standards that let me show them to you in a browser.
At the end of it all, a plant’s desire for sunlight held the key to making nice colors for my little chart.