color.js icon indicating copy to clipboard operation
color.js copied to clipboard

Add interpolation support for OkLrCH

Open KeyboardDanni opened this issue 1 year ago • 7 comments

Hi, I am working on an app to generate shades of colors for game artwork. During development I noticed that when using steps() to lerp over OkLCH, the result is too dark at lower luminance:

2024-04-20 15_39_52-Palettejuicer

It looks like I'm not the only one who noticed this issue.

It would be nice if OkLrCH were an option for interpolation, as I want to generate shades of color with roughly linear perceptual luminance, and the Lr function mentioned here seems to give results close to that of LAB. The code for the function already seems to be in the library via the OkHSL colorspace. Would it be possible to repurpose this as OkLrCH for better luminance transitions in OkLCH?

KeyboardDanni avatar Apr 20 '24 19:04 KeyboardDanni

I don't think OkLrCh has to be added to solve this problem. If you want shades using Lab lightness, just calculate lab lightness. This isn't to argue against adding OkLrCh, but more stating that it isn't expressly needed if all you want to do is create tints and shades.

const shades = (color, steps) => {
    const oklch = color.to('oklch');
    const lab1 = color.to('lab').set({'a': 0, 'b': 0});
    const lab2 = lab1.clone().set('l', 0);
    return lab1.steps(lab2, {space: 'lab', steps: steps}).map(lab => {
        return oklch.clone()
                    .set('l', lab.to('oklab').l)
                    .toGamut('srgb');
    });
};

// Shades using lab lightness
shades(new Color("#777777"), 8);
// Shades using oklab lightness
new Color("#777777").steps('black', {steps: 8})
Screenshot 2024-04-20 at 3 23 10 PM

facelessuser avatar Apr 20 '24 21:04 facelessuser

In my case I'm doing more than just varying luminance. A big part of designing colors for pixel art is also varying hue and saturation/chroma. The goal of this project is to provide a sandbox for mixing and interpolating colors across a variety of colorspaces and channels on a 2D grid.

LAB/LCH has some weaknesses here where the hue doesn't stay consistent, while OkLCH produces a better result. I have considered writing my own function to handle interpolation, particularly mapping L to Lr and back via toe/toeInv but am unsure about the ramifications of doing this myself (I'm still learning about color science).

KeyboardDanni avatar Apr 20 '24 22:04 KeyboardDanni

Keep in mind, in my example, we are not relying on Lab or LCh's hue preservation, we are only interpolating its lightness. We could apply this to interpolating between any two colors as well.

Here we interpolate the lightness within Lab for better distribution of lightness and we interpolate the chroma and hue within OkLCh. We return the lightness converted back to OkLCh and the chroma and hue as interpolated in OKLch.

const interpolate = (color1, color2, steps) => {
    const oklch1 = color1.to('oklch');
    const oklch2 = color2.to('oklch');
    const lab1 = color1.to('lab').set({'a': 0, 'b': 0});
    const lab2 = color2.to('lab').set({'a': 0, 'b': 0});
    const colors = oklch1.steps(oklch2, {space: 'oklch', steps: steps});
    return lab1.steps(lab2, {space: 'lab', steps: steps}).map((lab, i) => {
        return colors[i]
                    .set('l', lab.to('oklab').l);
    });
};

// Shades using lab lightness
interpolate(new Color("red"), new Color("green"), 8);
Screenshot 2024-04-20 at 5 18 50 PM

You are welcome to use the toe/toeInv to convert OkLCh lightness and interpolate just that channel, then convert it back to OKLCh's normal lightness as well. The toe/toeInv sets middle gray equivalent to Lab's middle gray, but it does not completely give you the same lightness distribution as Lab, but if that is sufficient for your needs, that would work. Alternatively, you apply the same logic above, but use Okhsl which applies that toe/toeinv:

const interpolate = (color1, color2, steps) => {
    const oklch1 = color1.to('oklch');
    const oklch2 = color2.to('oklch');
    const okhsl1 = color1.to('okhsl').set({'h': 0, 's': 0});
    const okhsl2 = color2.to('okhsl').set({'h': 0, 's': 0});
    const colors = oklch1.steps(oklch2, {space: 'oklch', steps: steps});
    return okhsl1.steps(okhsl2, {space: 'okhsl', steps: steps}).map((lab, i) => {
        return colors[i].set('l', lab.to('oklab').l);
    });
};

// Shades using lab lightness
interpolate(new Color("red"), new Color("green"), 8);
Screenshot 2024-04-20 at 5 21 54 PM

I don't think Okhsl and Okhsv are formally in the most recent release, but they are on the main branch waiting to be released.

facelessuser avatar Apr 20 '24 23:04 facelessuser

I don't think OkLrCh has to be added to solve this problem. If you want shades using Lab lightness, just calculate lab lightness.

True, but it would be useful to add it as an option; we have so many now.

We should also add some guidance to the start of "supported color space" because it has now become a bit overwhelming to people just getting started.

svgeesus avatar May 01 '24 04:05 svgeesus

It's easy enough to add the space, but are we wanting to add both the Oklab and OkLCH space with this adjusted lightness?

facelessuser avatar May 01 '24 04:05 facelessuser

That would seem logical (I increasingly see the a,b spaces as just an intermediate stage to the polar ones)

svgeesus avatar May 01 '24 05:05 svgeesus

PR is up

facelessuser avatar May 02 '24 13:05 facelessuser