← Chris' Stuff

Why Oklab is Great, Even in 13kbs, Meet Golch

In my 2026 js13k games Uniphony and Panzercorn, I started using the Oklab color space to get its nice blending properties. Here's how I fit that into 13kb games.

Colors for computer graphics tend to be specified in the "native" format of displays, as RGB triplets. Traditionally that was most often 8 bit values, so they would be specified as something like [255, 0, 0] for red, or perhaps packed into a hexadecimal number like 0xFF0000 instead. In shaders, that's normalized to a 0-1 value instead, so you a dark red might be [0.2, 0, 0].

There are a lot of other ways you could specify a color though! Each way is generally referred to as a color space, and each has its pros and cons, like giving you different behaviors when you blend them together. OkLab is a great one, primarily because linear blending between any two colors produces really pleasant transitions. I've been trying to use it all over the place, including the js13k jam. Because I've golfed it down and mangled it a bit, in fairness to the original I'm going to branch off and call my little fella Golch instead. (You know because it's golfed, and also because it's slightly less than OK, iykyk)

Oklab itself is the work of Björn Ottosson, and you can read his wonderfully crafted article on the many wonders of OkLab on his site, which dives very deeply into why it's nice to use.

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:

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