Source

gamut.js

import {
  clamp,
  degToRad,
  lerp,
  clampedRGB,
  isRGBInGamut,
  vec3,
} from "./util.js";
import { OKLab_to_LMS_M } from "./conversion_matrices.js";
import { sRGBGamut, OKLCH, OKLab } from "./spaces.js";
import { OKLab_to, convert } from "./core.js";

const DEFAULT_ALPHA = 0.05;

/**
 * @typedef {function} GamutMapMethod
 * @description A function that maps an OKLCH color to a lightness value.
 * @param {Vector} oklch The input OKLCH color
 * @param {number[]} cusp A 2D cusp point in the form [L, C]
 * @category mapping
 */

/**
 * A {@link GamutMapMethod} that maintains the color's lightness.
 * @type {GamutMapMethod}
 * @category mapping
 */
export const MapToL = (oklch) => oklch[0];
/**
 * A {@link GamutMapMethod} that maps towards middle gray (L = 0.5).
 * @type {GamutMapMethod}
 * @category mapping
 */
export const MapToGray = () => 0.5;
/**
 * A {@link GamutMapMethod} that maps towards the lightness of the current hue's cusp.
 * @type {GamutMapMethod}
 * @category mapping
 */
export const MapToCuspL = (_, cusp) => cusp[0];
/**
 * A {@link GamutMapMethod} that adaptively maps towards gray.
 * @type {GamutMapMethod}
 * @category mapping
 */
export const MapToAdaptiveGray = (oklch, cusp) => {
  const Ld = oklch[0] - cusp[0];
  const k = 2 * (Ld > 0 ? 1 - cusp[0] : cusp[0]);
  const e1 = 0.5 * k + Math.abs(Ld) + (DEFAULT_ALPHA * oklch[1]) / k;
  return (
    cusp[0] +
    0.5 * (Math.sign(Ld) * (e1 - Math.sqrt(e1 * e1 - 2 * k * Math.abs(Ld))))
  );
};
/**
 * A {@link GamutMapMethod} that adaptively maps towards the cusp's lightness.
 * @type {GamutMapMethod}
 * @category mapping
 */
export const MapToAdaptiveCuspL = (oklch) => {
  const Ld = oklch[0] - 0.5;
  const e1 = 0.5 + Math.abs(Ld) + DEFAULT_ALPHA * oklch[1];
  return (
    0.5 * (1 + Math.sign(Ld) * (e1 - Math.sqrt(e1 * e1 - 2.0 * Math.abs(Ld))))
  );
};

const floatMax = Number.MAX_VALUE;

const tmp2 = [0, 0];
const tmp3 = vec3();

// performs dot between vec3 A and B, but only on the YZ channels
const dotYZ = (a, b) => a[1] * b[1] + a[2] * b[2];

// regular dot product for 2D and 3D vectors
const dotXY = (a, b) => a[0] * b[0] + a[1] * b[1];
const dotXYZ = (vec, x, y, z) => vec[0] * x + vec[1] * y + vec[2] * z;

const setXY = (v, a, b) => {
  v[0] = a;
  v[1] = b;
};

const setYZ = (v, a, b) => {
  v[1] = a;
  v[2] = b;
};

/**
 * Computes the maximum saturation (S = C/L) possible for a given hue that fits within
 * the RGB gamut, using the given coefficients.
 * @param {number} a The normalized a component of the hue.
 * @param {number} b The normalized b component of the hue.
 * @param {Matrix3x3} lmsToRgb The LMS to RGB conversion matrix.
 * @param {ColorGamutCoefficients} okCoeff The OKLab coefficients.
 * @returns {number} The maximum saturation.
 * @method
 * @category mapping
 */
export const computeMaxSaturationOKLC = (a, b, lmsToRgb, okCoeff) => {
  // https://github.com/color-js/color.js/blob/main/src/spaces/okhsl.js
  // Finds the maximum saturation possible for a given hue that fits in RGB.
  //
  // Saturation here is defined as `S = C/L`.
  // `a` and `b` must be normalized so `a^2 + b^2 == 1`.

  // Max saturation will be when one of r, g or b goes below zero.

  // Select different coefficients depending on which component goes below zero first.

  let k0, k1, k2, k3, k4, wl, wm, ws;

  setXY(tmp2, a, b);
  setYZ(tmp3, a, b);

  let chnlCoeff, chnlLMS;
  // TODO: check performance of array destructuring...
  if (dotXY(okCoeff[0][0], tmp2) > 1) {
    // Red component
    chnlCoeff = okCoeff[0][1];
    chnlLMS = lmsToRgb[0];
  } else if (dotXY(okCoeff[1][0], tmp2) > 1) {
    // Green component
    chnlCoeff = okCoeff[1][1];
    chnlLMS = lmsToRgb[1];
  } else {
    // Blue component
    chnlCoeff = okCoeff[2][1];
    chnlLMS = lmsToRgb[2];
  }

  k0 = chnlCoeff[0];
  k1 = chnlCoeff[1];
  k2 = chnlCoeff[2];
  k3 = chnlCoeff[3];
  k4 = chnlCoeff[4];
  wl = chnlLMS[0];
  wm = chnlLMS[1];
  ws = chnlLMS[2];

  // Approximate max saturation using a polynomial:
  let sat = k0 + k1 * a + k2 * b + k3 * (a * a) + k4 * a * b;

  // Do one step Halley's method to get closer.
  // This gives an error less than 10e6, except for some blue hues where the `dS/dh` is close to infinite.
  // This should be sufficient for most applications, otherwise do two/three steps.

  let kl = dotYZ(OKLab_to_LMS_M[0], tmp3);
  let km = dotYZ(OKLab_to_LMS_M[1], tmp3);
  let ks = dotYZ(OKLab_to_LMS_M[2], tmp3);

  let l_ = 1.0 + sat * kl;
  let m_ = 1.0 + sat * km;
  let s_ = 1.0 + sat * ks;

  let l = l_ * l_ * l_;
  let m = m_ * m_ * m_;
  let s = s_ * s_ * s_;

  let lds = 3.0 * kl * (l_ * l_);
  let mds = 3.0 * km * (m_ * m_);
  let sds = 3.0 * ks * (s_ * s_);

  let lds2 = 6.0 * (kl * kl) * l_;
  let mds2 = 6.0 * (km * km) * m_;
  let sds2 = 6.0 * (ks * ks) * s_;

  let f = wl * l + wm * m + ws * s;
  let f1 = wl * lds + wm * mds + ws * sds;
  let f2 = wl * lds2 + wm * mds2 + ws * sds2;

  sat = sat - (f * f1) / (f1 * f1 - 0.5 * f * f2);

  return sat;
};

/**
 * Retrieves the LMS to RGB conversion matrix from the given gamut.
 * @param {ColorGamut} gamut The gamut object.
 * @returns {Matrix3x3} The LMS to RGB conversion matrix.
 * @method
 * @category mapping
 */
export const getGamutLMStoRGB = (gamut) => {
  if (!gamut) throw new Error(`expected gamut to have { space }`);
  const lmsToRGB = (gamut.space.base ?? gamut.space).fromLMS_M;
  if (!lmsToRGB)
    throw new Error(`expected gamut { space } to have a fromLMS_M matrix`);
  return lmsToRGB;
};

/**
 * Finds the cusp of the OKLCH color space for a given hue.
 * @param {number} a The normalized a component of the hue.
 * @param {number} b The normalized b component of the hue.
 * @param {ColorGamut} gamut The gamut object.
 * @param {number[]} [out=[0, 0]] The output array to store the cusp values.
 * @returns {number[]} The cusp values [L, C].
 * @method
 * @category mapping
 */
export const findCuspOKLCH = (a, b, gamut, out = [0, 0]) => {
  const lmsToRgb = getGamutLMStoRGB(gamut);
  const okCoeff = gamut.coefficients;
  if (!okCoeff) throw new Error("expected gamut to have { coefficients }");
  // const lmsToRgb, okCoeff
  // First, find the maximum saturation (saturation S = C/L)
  var S_cusp = computeMaxSaturationOKLC(a, b, lmsToRgb, okCoeff);
  // Convert to linear RGB to find the first point where at least one of r,g or b >= 1:
  tmp3[0] = 1;
  tmp3[1] = S_cusp * a;
  tmp3[2] = S_cusp * b;
  var rgb_at_max = OKLab_to(tmp3, lmsToRgb, tmp3);
  var L_cusp = Math.cbrt(
    1 / Math.max(Math.max(rgb_at_max[0], rgb_at_max[1]), rgb_at_max[2])
  );
  var C_cusp = L_cusp * S_cusp;
  out[0] = L_cusp;
  out[1] = C_cusp;
  return out;
};

export const findGamutIntersectionOKLCH = (a, b, l1, c1, l0, cusp, gamut) => {
  // Finds intersection of the line.
  //
  // Defined by the following:
  //
  // ```
  // L = L0 * (1 - t) + t * L1
  // C = t * C1
  // ```
  //
  // `a` and `b` must be normalized so `a^2 + b^2 == 1`.

  let t;

  const lmsToRgb = getGamutLMStoRGB(gamut);
  if (!cusp) throw new Error("must pass cusp");

  setYZ(tmp3, a, b);

  // Find the intersection for upper and lower half separately
  if ((l1 - l0) * cusp[1] - (cusp[0] - l0) * c1 <= 0.0) {
    const denom = c1 * cusp[0] + cusp[1] * (l0 - l1);
    // Lower half
    t = denom === 0 ? 0 : (cusp[1] * l0) / denom;
  } else {
    // Upper half

    // First intersect with triangle
    const denom = c1 * (cusp[0] - 1.0) + cusp[1] * (l0 - l1);
    t = denom === 0 ? 0 : (cusp[1] * (l0 - 1.0)) / denom;

    // Then one step Halley's method
    let dl = l1 - l0;
    let dc = c1;

    let kl = dotYZ(OKLab_to_LMS_M[0], tmp3);
    let km = dotYZ(OKLab_to_LMS_M[1], tmp3);
    let ks = dotYZ(OKLab_to_LMS_M[2], tmp3);

    let ldt_ = dl + dc * kl;
    let mdt_ = dl + dc * km;
    let sdt_ = dl + dc * ks;

    // If higher accuracy is required, 2 or 3 iterations of the following block can be used:
    let L = l0 * (1.0 - t) + t * l1;
    let C = t * c1;

    let l_ = L + C * kl;
    let m_ = L + C * km;
    let s_ = L + C * ks;

    let l = l_ * l_ * l_;
    let m = m_ * m_ * m_;
    let s = s_ * s_ * s_;

    let ldt = 3 * ldt_ * l_ * l_;
    let mdt = 3 * mdt_ * m_ * m_;
    let sdt = 3 * sdt_ * s_ * s_;

    let ldt2 = 6 * ldt_ * ldt_ * l_;
    let mdt2 = 6 * mdt_ * mdt_ * m_;
    let sdt2 = 6 * sdt_ * sdt_ * s_;

    let r_ = dotXYZ(lmsToRgb[0], l, m, s) - 1;
    let r1 = dotXYZ(lmsToRgb[0], ldt, mdt, sdt);
    let r2 = dotXYZ(lmsToRgb[0], ldt2, mdt2, sdt2);

    let ur = r1 / (r1 * r1 - 0.5 * r_ * r2);
    let tr = -r_ * ur;

    let g_ = dotXYZ(lmsToRgb[1], l, m, s) - 1;
    let g1 = dotXYZ(lmsToRgb[1], ldt, mdt, sdt);
    let g2 = dotXYZ(lmsToRgb[1], ldt2, mdt2, sdt2);

    let ug = g1 / (g1 * g1 - 0.5 * g_ * g2);
    let tg = -g_ * ug;

    let b_ = dotXYZ(lmsToRgb[2], l, m, s) - 1;
    let b1 = dotXYZ(lmsToRgb[2], ldt, mdt, sdt);
    let b2 = dotXYZ(lmsToRgb[2], ldt2, mdt2, sdt2);

    let ub = b1 / (b1 * b1 - 0.5 * b_ * b2);
    let tb = -b_ * ub;

    tr = ur >= 0.0 ? tr : floatMax;
    tg = ug >= 0.0 ? tg : floatMax;
    tb = ub >= 0.0 ? tb : floatMax;

    t += Math.min(tr, Math.min(tg, tb));
  }

  return t;
};

/**
 * Applies fast approximate gamut mapping in OKLab space on the given OKLCH input color,
 * using the specified gamut and converting to the target color space.
 * @param {Vector} oklch The input OKLCH color that you wish to gamut map.
 * @param {ColorGamut} [gamut=sRGBGamut] The gamut object.
 * @param {ColorSpace} [targetSpace=gamut.space] The target color space.
 * @param {Vector} [out=vec3()] The output array to store the mapped color.
 * @param {GamutMapMethod} [mapping=MapToCuspL] The gamut mapping function.
 * @param {Vector} [cusp] Optional, you can provide the cusp values [L, C] to avoid re-computing them.
 * @returns {Vector} The mapped color in the target color space.
 * @method
 * @category mapping
 */
export const gamutMapOKLCH = (
  oklch,
  gamut = sRGBGamut,
  targetSpace = gamut.space,
  out = vec3(),
  mapping = MapToCuspL,
  cusp
) => {
  const gamutSpace = gamut.space;
  const coeff = gamut.coefficients;
  if (!coeff || !gamutSpace) {
    throw new Error(`expected gamut with { space, coefficients }`);
  }
  const gamutSpaceBase = gamutSpace.base ?? gamutSpace;
  const lmsToRgb = gamutSpaceBase.fromLMS_M;
  if (!lmsToRgb) {
    throw new Error(
      `color space ${outSpace.id} has no base with LMS to RGB matrix`
    );
  }

  // tmp output for R,G,B
  const rgbVec = tmp3;

  // first, let's clamp lightness and chroma
  out[0] = clamp(oklch[0], 0, 1);
  out[1] = Math.max(oklch[1], 0);
  out[2] = oklch[2]; // hue remains constant

  // convert oklch to base gamut space (i.e. linear sRGB)
  convert(out, OKLCH, gamutSpaceBase, rgbVec);

  // Note: this is a possible area of performance improvement for some edge cases
  // If you gamut map without clipping, you end up lying on the edge of the gamut,
  // but in some cases very slightly out of gamut. Gamut mapping *again* is redundant
  // as it will produce the same result; and in those cases, it should just skip straight
  // to clipping. So, in theory, a small epsilon like 1e-7 would catch these and prevent redundant gamut mapping.
  // See test/check-gamut-epsilon.js
  // However, in practice, inputs to this function are likely not going to be already-mapped-but-not-clipped points,
  // so we are talking about a very negligible improvement, and it is probably better to be accurate in as many cases
  // as possible than to shave off a little time.
  const RGB_CLIP_EPSILON = 0;

  // check where the point lies in gamut space
  if (!isRGBInGamut(rgbVec, RGB_CLIP_EPSILON)) {
    // we aren't in gamut, so let's map toward it
    const L = out[0];
    const C = out[1];
    const H = out[2];
    const hueAngle = degToRad(H);
    const aNorm = Math.cos(hueAngle);
    const bNorm = Math.sin(hueAngle);

    // choose our strategy
    cusp = cusp || findCuspOKLCH(aNorm, bNorm, gamut, tmp2);
    const LTarget = mapping(out, cusp);

    let t = findGamutIntersectionOKLCH(
      aNorm,
      bNorm,
      L,
      C,
      LTarget,
      cusp,
      gamut
    );
    out[0] = lerp(LTarget, L, t);
    out[1] *= t;

    // Note: if requested targetSpace is base=OKLCH, we can return early.
    // note this creates a potential difference compared to going to other targetSpaces:
    // a) mapping to sRGB then finally converting back to OKLCH the result may be lossy in
    //    some coordinates that you might not expect, e.g. hue loss when hue should be unchanged
    //    during gamut mapping, due to conversion / floating point math
    // b) if you map to sRGB it will perform clipping, but if you map to OKLCH then no sRGB clipping
    //    will be applied. The OKLCH result is _basically_ in gamut, but not exactly; you'll need to clip at final stage.
    // I've documented this behaviour in the readme.
    const targetSpaceBase = targetSpace.base ?? targetSpace;
    if (targetSpaceBase.id == "oklab") {
      return convert(out, OKLCH, targetSpace, out);
    }

    // now that we have a LCH that sits on (or nearly on) the gamut, convert again to linear space
    convert(out, OKLCH, gamutSpaceBase, rgbVec);
  }
  // clip the linear RGB to 0..1 range
  clampedRGB(rgbVec, rgbVec);
  // finally, convert linear RGB to the final target space (e.g. sRGB or XYZ)
  // this is often just a linear to gamma transfer, unless another target space is specified
  convert(rgbVec, gamutSpaceBase, targetSpace, out);
  return out;
};