UNPKG

@texel/color

Version:

a minimal and modern color library

423 lines (361 loc) 13.5 kB
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; };