DEV Community

Cover image for Fastest color conversion and blending in JS with typed arrays
Vipertech.ch
Vipertech.ch

Posted on

Fastest color conversion and blending in JS with typed arrays

Hi,

I was building a pixel art editor which is https://pixa.pics/ and I made a lot of research to get the best performance out of this UI... And I had chose the 2D context of canvas for manipulating up to 512x512 images, drawing on them, floodfill some parts of the image with comparing colors and so on...

It works great even on my $100 Samsung J3 just try out! ;)

But when it comes to our layering systems, we had to blend colors which are stored in in hex (r, g, b, a) as a string like "#e2e2e2ff" with different opacity and weight...

First we needed to format colors to ensure "#fff" (#rgb) or "#fffe" (#rgba) gets "#ffffffff" (#rrggbbaa):

function format_color(color) { // Supports #fff (short rgb), #fff0 (short rgba), #e2e2e2 (full rgb) and #e2e2e2ff (full rgba)

        const hex = color || "#00000000";
        const hex_length = hex.length;

        if(hex_length === 9) {

            return hex;

        } else if (hex_length === 7) {

            return hex.concat("ff");
        } else if (hex_length === 5) {

            const a = hex.charAt(1), b = hex.charAt(2), c = hex.charAt(3), d = hex.charAt(4);
            return "#".concat(a, a, b, b, c, c, d, d);
        } else if (hex_length === 4) {

            const a = hex.charAt(1), b = hex.charAt(2), c = hex.charAt(3);
            return "#".concat(a, a, b, b, c, c, "ff");
        }
    }

Enter fullscreen mode Exit fullscreen mode

Then we use a unsigned int of 32 bytes (an array of those to convert them to an array of unsigned int of 8 bytes which range from 0-255 in base 10.

function get_rgba_from_hex(hex) {

        return new Uint8ClampedArray(Uint32Array.of(parseInt(hex.slice(1), 16)).buffer).reverse();
}
Enter fullscreen mode Exit fullscreen mode

So what it performs is that it take the hex string and then remove the first character so we reduce it to a 8 length string of hexadecimal (base 16) values which are converted into a Integer which is usually and can get displayed to a Number in base 10, it fits perfectly into a Uint32 and forms an array with one element that gets to create what we told you before, which is to say, an array of Uint8 numbers (0-255) that we create from the "binary" values of our set of one big number of 32 Bytes (that's why we call the buffer of the array) and then as we would get [a, r, g, b] we then finally call the reverse method.

That's fast because we only slice the first char convert it into a number that got spitted into 4 numbers which are fixed to be between 0 to 255 that we arrange to return in the correct order.

Then we have the inverse operation to do ... well.. the inverse operation and it doesn't need the reverse method since we put them in the reverse order "manually" but the trick here is that sometimes when we get the hex string without the hashtag it lacks some zeros that needs to be padded at the begging of the string, and padstart function is low in performance so we add 8 zeros and get the 8 chars of from the end of the string.

function get_hex_color_from_rgba_values (r, g, b, a) {

        return "#".concat("00000000".concat(new Uint32Array(Uint8ClampedArray.of(a, b, g, r).buffer)[0].toString(16)).slice(-8));
}
Enter fullscreen mode Exit fullscreen mode

SO! Then there we also got HSL color conversion but lacking some innovative thoughts this is the very usual functions:

function rgb_to_hsl (r, g, b) {

        r /= 255, g /= 255, b /= 255;
        const max = Math.max(r, g, b), min = Math.min(r, g, b);
        let h, s, l = (max + min) / 2;

        if(max === min){
            h = s = 0; // achromatic
        }else {
            const d = max - min;
            s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
            switch(max){
                case r: h = (g - b) / d + (g < b ? 6 : 0); break;
                case g: h = (b - r) / d + 2; break;
                case b: h = (r - g) / d + 4; break;
            }
            h /= 6;
        }

        return Array.of(parseInt(h * 360), parseInt(s * 100), parseInt(l * 100));
}


function hsl_to_rgb(h, s, l) {

        h /= 360, s /= 100, l /= 100;

        let r, g, b;
        if (s === 0) {
            r = g = b = l;
        } else {
            const hue_to_rgb = function(p, q, t) {
                if (t < 0) t += 1;
                if (t > 1) t -= 1;
                if (t < 1 / 6) return p + (q - p) * 6 * t;
                if (t < 1 / 2) return q;
                if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
                return p;
            };
            const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
            const p = 2 * l - q;
            r = hue_to_rgb(p, q, h + 1 / 3);
            g = hue_to_rgb(p, q, h);
            b = hue_to_rgb(p, q, h - 1 / 3);
        }

        return Uint8ClampedArray.of(r * 255, g * 255, b * 255);
}
Enter fullscreen mode Exit fullscreen mode

I won't explain those function they are a bit complex but here we go, everything works fine!

But hey let's looks at blending ;)

blend_colors (color_a, color_b, amount = 1, should_return_transparent = false, alpha_addition = false) => {

        color_a = format_color(color_a);
        // If we blend the first color with the second with 0 "force", return transparent
        if(amount === 0 && color_b !== "hover" && should_return_transparent) {

            return "#00000000";
        }

        // Make sure we have a color based on the 4*2 hex char format

        if(color_b === "hover") {

            const rgba = get_rgba_from_hex(color_a);
            const hsl = rgb_to_hsl(rgba[0], rgba[1], rgba[2], rgba[3]);
            const rgb = hsl_to_rgb(hsl[0], hsl[1], parseInt(hsl[2] >= 50 ? hsl[2]/2: hsl[2]*2));
            color_b = get_hex_color_from_rgba_values(rgb[0], rgb[1], rgb[2], 255);
        }else {

            color_b = format_color(color_b);
        }
        // If the second color is transparent, return transparent (useful when we need to use transparent to "erase"
        if(should_return_transparent && color_b === "#00000000" && amount === 1) { return "#00000000"; }

        // Extract RGBA from both colors
        const base = get_rgba_from_hex(color_a);
        const added = get_rgba_from_hex(color_b);

        // If the opacity of color B is full and that we blend with an amount of 1, we directly return the color B 
        if(added[3] === 255 && amount === 1) { return color_b; }

        const ba3 = base[3] / 255;
        const ad3 = (added[3] / 255) * amount;

        let mix = new Uint8ClampedArray(4);
        let mi3 = 0;

        if (ba3 > 0 && ad3 > 0) {

            if(alpha_addition) { // Sometimes we add

                mi3 = ad3 + ba3;
            }else { // Sometimes we blend

                mi3 = 1 - ((1 - ad3) * (1 - ba3));
            }

            const ao = ad3 / mi3;
            const bo = ba3 * (1 - ad3) / mi3;

            mix[0] = parseInt(added[0] * ao + base[0] * bo); // red
            mix[1] = parseInt(added[1] * ao + base[1] * bo); // green
            mix[2] = parseInt(added[2] * ao + base[2] * bo); // blue
        }else if(ad3 > 0) {

            mi3 = added[3] / 255;

            mix[0] = added[0];
            mix[1] = added[1];
            mix[2] = added[2];
        }else {

            mi3 = base[3] / 255;

            mix[0] = base[0];
            mix[1] = base[1];
            mix[2] = base[2];
        }

        if(alpha_addition) {
            mi3 /= 2;
        }

        mix[3] = parseInt(mi3 * 255);

        return get_hex_color_from_rgba_values(mix[0], mix[1], mix[2], mix[3]);
}
Enter fullscreen mode Exit fullscreen mode

CODE: https://gist.github.com/vipertechofficial/c5353b4df4910aebffed4f0a07fd725e

Have a nice week, ;)
Enjoy!

Discussion (0)