Try before you buy
The best way to get an intuitive understanding of the space is to try it out. Go ahead, pick a color, any color, and you'll get the color value in Golch, and also the same color as Oklab, Oklch, and sRGB in various units.
hue (0-35 horizontal) x chroma (0-9 vertical), at the current lightness
lightness 0 - 15, at the current hue and chroma
just hue, 0-35 at the current lightness and chroma
It's all about the unit
OKlab is amazing for blending, as you can see from the original documentation, but it's Oklch that is the artist friendly format. That's OK lightness, chrominance, and hue. Hue is which color you want; chrominance is how intense it should be; and lightness is how bright, with low lightness being black, and high lightness going to white.
The goal with Golch is to let us specify nice Oklch colors with the least possible bytes in JavaScript, so the first thing we can do is work out how much range in each part we really need. Ideally we want to specify all our colors with whole integers. Bear in mind that fractions still work, we're just avoiding them for space. After some experimentation, I figured:
- Lightness in Oklch should go from 0-1. We can subdivide that into 10 steps with a single digit, which is plenty of range between black and white for a golfed game, so Golch lightness is 0-9. Overbright colors are often useful, so it's worth pointing out you can still use values greater than 9 here. The color picker above goes up to 15 to show you what happens up there. If it turns out you need those a lot, there's no reason not to consider shifting the scale of your 0-9 and letting it climb up there.
- The useful range of Chrominance in Oklch is about 0 to 0.3. The sRGB gamut varies by hue and lightness, so some colors leave it before that point. Single digit still covers a useful range, giving us gray to fully saturated for every hue.
- We're not going to get away with just 10 Hue steps, if only because 10 subdivisions of the hue circle leaves us with some awkward ones, so we'll need to break into double digits. After some rumination, I went with 32 steps in the jam, which was dumb because 36 is right there. 36 means you can think of the space being 360 degrees wide, and do stuff like add 18 to find a complementary hue. So I'm going to pretend I always thought it should be 36.
Set it and forget it
One of the really nice properties of this space is that colors with the same lightness property tend to be perceptually the same brightness. The same with chrominance. So picking a fixed lightness and chrominance, but cycling the hue, gets you a set of colors that all seem to have the same intensity. Pick a pastel, change the hue, and you get another pastel. Moreover, blending between them largely passes through other pastels, without dipping to grey, rising to washed out, or detouring into other hues.
Because of that property, I think it's entirely possible for some games to just pick a default lightness and chrominance, and specify colors entirely using just hue. In that case, your color storage has collapsed to just a 2 digit number!
Getting back to sRGB
To use these colors, we have to figure out a way back to the APIs. I was doing all WebGL, and I was blending colors in the shaders, so I needed to send the Oklab form to the shader, let it work on that, then do one conversion at the end to sRGB.
The conversion from Golch to Oklch is just scaling by our range terms, and the Oklch to Oklab is very easy: the lightness stays the same, with a range of 0-1, while the a and b terms are actually like a circle: the hue is where around the circle you are, while the chrominance is how far from the center. See how this code looks a lot like the code for a point on a circle.
export const setUniformColorGOLCH = (
uniform: WebGLUniformLocation,
hue: number,
lightness: number = defaultLightness,
chroma: number = defaultChroma,
) => {
chroma = chroma * 0.3 / 9;
hue = hue * pi2 / 36;
gl.uniform3f(uniform, lightness / 9, chroma * cos(hue), chroma * sin(hue));
};
Once we're in the shader, the actual conversion is a couple of matrix multiplies. This is quite a bit of code (mainly the constants), but bear in mind we're also getting that nice Oklab blending out of this!
// vec3 lab is your final Oklab color in l,a,b format.
lab = mat3( 1, 1, 1,
.3963, -.1056, -.0895,
.2158, -.0639, -1.29) * lab;
// lab is now cube-root LMS
lab *= lab * lab;
// lab is now linear LMS, a human-eye cone-response space
lab = mat3( 4.077, -1.268, -.004,
-3.308, 2.6, -.7034,
.23, -.3413, 1.708) * lab;
// lab is now linear sRGB
outputColor.rgb = mix(
lab * 12.92,
1.055 * pow(max(lab, 0.), vec3(1. / 2.4)) - .055,
step(.0031, lab)
);
// outputColor is now display sRGB
This is where we could say Oklab really turns into Golch: the values in the matrices above are truncated from the real Oklab ones. I basically just cut digits off until I started getting values that deviated too far. I can barely tell the difference, but these aren't the actual Oklab numbers, so it's not technically the same space anymore! This means you can't really use other color pickers and related Oklab tools, you'll need to use your own, like the handy one on this page :)
That final mix step going to display sRGB is an approximation of the
official one, with the little toe that changes how dark colors look. I
like it, but I'm a dork and you maybe couldn't care less, so you could
swap that out with a simpler conversion like
pow(max(lab, 0.), vec3(1. / 2.2)).
Thanks, GLHF!
That's it for this article. I am very much looking forward to using this again next year, and in general just to come back and do another JS13k. It's super fun to carry forward ideas from year to year, and I hope this one will be useful in your games!
One last thing... surely you could golf harder?
I said we probably need more than 10 hues... but that's not going to be true for everyone! You might have a case where you need that byte back, say if you have a LOT of colors to specify in some data stream. Well, here's an exploration of using a single digit hue instead, with the addition of offset and scale values you could use to map the 10 values to some subset of the whole hue circle. Bear in mind again that you're not prevented from picking any other color, it's just that now anything other than your chosen 10 will end up needing more than 1 digit.
short hue (0-9) x chroma (0-9), with offset and scale applied
lightness, still 0-15, at the current golfed hue and chroma
short hue, 0-9 at the current lightness and chroma
scale cuts down the range of hues represented by the 0-9 digits, letting you increase resolution in some area you know you're going to use lots of colors
offset is added to the hue after scale, it moves where the 0-9 scale starts, letting you align the 0-9 hues with some key ones you need to hit
here's the full hue circle again, with the 0-9 range indicated with the black square