I'm going to ask you to look at some pixels now.
Both of these closeups show the corner of a bright yellow (#f1bd02) polygon on a bright blue (#1eb9e7) background. Both are anti-aliased.
But, as I hope you can see, the blended pixels on the left image are darker and appear muddier than the ones on the right. This is because the left image is anti-aliased in gamma-corrected colorspace, while the right one uses linear colorspace.
A quick aside-- People and computers handle light & color differently.
If you wanted to represent a color with 1s and 0s, a naive approach might be to encode Red|Green|Blue channels as values from 0 to pow(2, 8). Unfortunately, human eyes don't perceive colors linearly; we see a lot more detail in darker tones than lighter ones. So a linear encoding like this is going to end up wasting memory storing a bunch of bright shades that your users can't differentiate anyway.
A better idea is to compress those brighter tones while saving memory for darker ones. And you can do this with a power law. This is not Computers Are Bad so I'm not going to get into the history of sRGB; suffice it to say 1) this translation is called "gamma correction" and it's generally done by raising each primary color channel's intensity to some exponent, roughly 2.2.
Cool!
So why do we care about this? Well, when you create some image in a <canvas> element where chromatically contrasting colors are blended together during anti-aliasing, you end up with gray/brown fringes as the sRGB gamma curve attempts to average non-linear color values and the overall perceived luminance drops significantly, i.e. it looks like crap.
With a little help from perbang.dk's fantastic 3D color wheel tool, this is perhaps a little easier to understand.
The straight line here is our direct "average" path through gamma-corrected space. (Remember that gamma-corrected colors are already the product of an exponential function; therefore a "straight" path here does not represent a series of linear deltas.) The curved path represents an actual lerp (linear interpolation) between these two colors, but to arrive at the colors along this arc -- the same colors you'd get if you put a semi-transparent yellow layer over a blue layer in GIMP -- we're going to have to help our <canvas> with a little math.
function hexToLinear(hex) {
// Convert hex color strings to RGBA floats, then translate these to linear colorspace
// Don't pass me 3-digit shorthand hex codes pls
const cleanHex = hex.replace('#', '');
const rInt = parseInt(cleanHex.substring(0, 2), 16);
const gInt = parseInt(cleanHex.substring(2, 4), 16);
const bInt = parseInt(cleanHex.substring(4, 6), 16);
const r = rInt / 255;
const g = gInt / 255;
const b = bInt / 255;
// This is the real gamma encoding function
const toLinear = c => c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
return {
r: toLinear(r),
g: toLinear(g),
b: toLinear(b)
};
}
function lerp(colorStart, colorEnd, amount) {
// A regular old lerp
const t = Math.max(0, Math.min(1, amount));
return {
r: colorStart.r + (colorEnd.r - colorStart.r) * t,
g: colorStart.g + (colorEnd.g - colorStart.g) * t,
b: colorStart.b + (colorEnd.b - colorStart.b) * t
}
}
function linearToSRGBSingle(channel) {
// Convert a single linear color value to sRGB
const clamped = Math.max(0, Math.min(1, channel));
return clamped <= 0.0031308 ? 12.92 * clamped : 1.055 * Math.pow(clamped, 1 / 2.4) - 0.055;
}
function linearToSRGB(color) {
// Translate a linear color to sRGB space
return {
r: Math.round(linearToSRGBSingle(color.r) * 255),
g: Math.round(linearToSRGBSingle(color.g) * 255),
b: Math.round(linearToSRGBSingle(color.b) * 255)
};
}
Here are all the helper functions we're going to need. These will handle converting our hex string colors sRGB floats linear colorspace floats and back again.
In the next section, we'll use these to draw our canvas, copy its contents to another, offscreen canvas, translate our colors pixel-by-pixel, then replace the visible canvas' data with our translated colors.
What's that? Just use colorSpace: "display-p3-linear"? Or WebGL / WebGPU?
No see I want to explain why this happens, not just how to fix it quickly. Also older browsers exist.
Here's our quad on a 2D canvas with linear colorspace anti-aliasing. No more fringe!
const canvas = document.getElementById('lerpCanvas');
const ctxOn = canvas.getContext('2d');
const w = canvas.width;
const h = canvas.height;
const offscreen = new OffscreenCanvas(w, h);
const ctxOff = offscreen.getContext('2d');
const linearBlue = hexToLinear(BLUE);
const linearYellow = hexToLinear(YELLOW);
// Draw quad on transparent offscreen canvas
ctxOff.fillStyle = YELLOW;
ctxOff.beginPath();
ctxOff.moveTo(w * .1, h * .1);
ctxOff.lineTo(w * .8, h * .2);
ctxOff.lineTo(w * .9, h * .9);
ctxOff.lineTo(w * .1, h * .9);
ctxOff.closePath();
ctxOff.fill();
// Extract pixel data
const imgData = ctxOff.getImageData(0, 0, w, h);
const pixels = imgData.data;
// Target antialiased pixels
for (let i = 0; i < pixels.length; i += 4) {
const alpha = pixels[i + 3];
// Lerp according to alpha (0 == blue; 255 == yellow)
if (alpha > 0 && alpha < 255) {
const lerpedColor = lerp(linearBlue, linearYellow, alpha / 255);
const srgbColor = linearToSRGB(lerpedColor);
pixels[i] = srgbColor.r;
pixels[i + 1] = srgbColor.g;
pixels[i + 2] = srgbColor.b;
pixels[i + 3] = 255; // All fringe pixels are now opaque lerped colors
}
}
ctxOff.putImageData(imgData, 0, 0);
ctxOn.fillStyle = BLUE;
ctxOn.fillRect(0, 0, w, h);
ctxOn.drawImage(offscreen, 0, 0);