Best JS Script Method To Change Color Hue Of A PNG Image At Runtime?

Hi,

We are working on version 2.0 of out HTML5/JavaScript cross-platform 2-D Web video game engine.
(Code Named: “Mustang GT 5.0 SuperCharged™”)

Does anyone know the best JavaScript script method to change color hue of a PNG image?
(changing image’s red, green, and blue hue)

What we currently use is listed below(but it is very CPU intensive - need something faster):

Let us know here, thanks!

Jesse

Game can be played and the full source code project can be downloaded on below webpage:
https://jesseleepalser.itch.io/t-crisis-v

//--------------------------------------------------------------------------------------------------------------
// "Retro Blast Tech"
function DrawSpriteOntoCanvas(index, x, y, scaleX, scaleY, rotationDegree, alpha, red, green, blue)
{
    if (scaleX === 0 || scaleY === 0)  return;

    let imageToDraw;
    let imageToDrawWidth;
    let imageToDrawHeight;

    if (index < 101 || index > 166)
    {
        imageToDraw = ImageSprites[index];
        imageToDrawWidth = ImageSprites[index].width;
        imageToDrawHeight = ImageSprites[index].height;
    }
    else
    {
        imageToDraw = document.createElement("canvas");
        imageToDraw.width = 39;
        imageToDraw.height = 30;
        imageToDrawWidth = 39;
        imageToDrawHeight = 30;
        imageToDraw = ButtonsWithCharsCanvases[index-100];
    }

    ctx.save();

    ctx.globalAlpha = alpha;

    if (red !== 255 || green !== 255 || blue !== 255)
    {
        let buff = document.createElement("canvas");
        buff.width  = imageToDrawWidth;
        buff.height = imageToDrawHeight;

        if (red !== 255)
        {
            let ctxR  = buff.getContext("2d");
            ctxR.drawImage(imageToDraw, 0, 0);

            ctxR.globalAlpha = (red / 255);
            ctxR.globalCompositeOperation = 'source-atop';
            ctxR.drawImage(ImageSprites[1], 0, 0);

            ctxR.globalAlpha = 1;

            imageToDraw = buff;
        }

        if (green !== 255)
        {
            let ctxG  = buff.getContext("2d");
            ctxG.drawImage(imageToDraw, 0, 0);

            ctxG.globalAlpha = (green / 255);
            ctxG.globalCompositeOperation = 'source-atop';
            ctxG.drawImage(ImageSprites[2], 0, 0);

            ctxG.globalAlpha = 1;

            imageToDraw = buff;
        }

        if (blue !== 255)
        {
            let ctxB  = buff.getContext("2d");
            ctxB.drawImage(imageToDraw, 0, 0);

            ctxB.globalAlpha = (blue / 255);
            ctxB.globalCompositeOperation = 'source-atop';
            ctxB.drawImage(ImageSprites[3], 0, 0);

            ctxB.globalAlpha = 1;

            imageToDraw = buff;
        }

        buff = null;
    }

    ctx.translate(x, y);

    if (rotationDegree !== 0)  ctx.rotate( DegToRad(rotationDegree) );
    
    if (scaleX !== 1 || scaleY !== 1)  ctx.scale(scaleX, scaleY);

    ctx.drawImage( imageToDraw, -(imageToDrawWidth / 2), -(imageToDrawHeight / 2) );

    ctx.globalAlpha = 1;
    ctx.restore();
}
//                                                                                            "Retro Blast Tech"

//--------------------------------------------------------------------------------------------------------------

I started to write a whole explanation of how hue doesnt work this way, and how to do the maths to convert RGB to HSL to spin the color wheel properly. If you want to look at the maths of how to do that, we can.
It’s not going to be easy (computationally speaking) to actually write the changed colors to a PNG; if would involve invoking ctx.getImageData and ctx.putImageData to read and write a pixel array, and modifying individual pixels.

A bit off topic, but you asked before about refactoring in a previous thread, and looking at your code, there are a few obvious code smells.

First off a function name that starts with a Capital letter generally indicates that the function is a Constructor function, similar to using class. They are not used as often these days, but it would be less confusing if your function ‘DrawSpriteOntoCanvas’ started with a lower case e.g. ‘drawSpriteOntoCanvas’ or simply ‘renderSprite’

Another issue that stands out to me is the number of parameters that function has e.g.
(index, x, y, scaleX, scaleY, rotationDegree, alpha, red, green, blue)

One typical way of dealing with this is to contain these properties inside an object. For instance

// sprite properties
{
  index: 0,
  position: { x: 0, y: 0 },
  scale: { x: 1, y: 1 },
  rotation: 0,
  rgba: {
    red: 255,
    green: 255,
    blue: 255,
    alpha: 1
  }
}

You could then ammend the function so that it expects one argument, the sprite properties object.

function renderSprite(spriteProps) {
    // destructure the properties
    const { index, position, scale, rotation, rgba } = sprite;

The next issue that stands out is with regards the DRY principle — don’t repeat yourself.

if (red !== 255 || green !== 255 || blue !== 255)
    {
        let buff = document.createElement("canvas");
        buff.width  = imageToDrawWidth;
        buff.height = imageToDrawHeight;

        if (red !== 255)
        {
            let ctxR  = buff.getContext("2d");
            ctxR.drawImage(imageToDraw, 0, 0);
            ctxR.globalAlpha = (red / 255);
            ctxR.globalCompositeOperation = 'source-atop';
            ctxR.drawImage(ImageSprites[1], 0, 0);
            ctxR.globalAlpha = 1;
            imageToDraw = buff;
        }

        if (green !== 255)
        {
           // ... pretty much same block of code here
        }
        if (blue !== 255)
        {
           // ... pretty much same block of code here
        }

You are basically repeating the same block of code three times. One way to deal with this, would be to wrap that repeated block inside of a function.

I don’t know exactly what the function does, so you will probably want a more appropriate name.

function adjustSpriteColour (colour, buff, imageToDraw, spriteImage) {
  // early return if colour is 255
  if (colour === 255) return;
  // otherwise
  const ctxR = buff.getContext('2d');

  ctxR.drawImage(imageToDraw, 0, 0);
  ctxR.globalAlpha = (colour / 255);
  ctxR.globalCompositeOperation = 'source-atop';
  ctxR.drawImage(spriteImage, 0, 0);
  ctxR.globalAlpha = 1;

  imageToDraw = buff;
}

Note I am not familiar enough with your code, so this is just a rough example.

You could then use a loop inside of renderSprite (or drawSpriteOntoCanvas) instead

// again destructure
const { red, green, blue, alpha } = rgba;
ctx.globalAlpha = alpha;

let buff = document.createElement('canvas');
buff.width = imageToDrawWidth;
buff.height = imageToDrawHeight;

// you could use array.forEach instead
for (const [index, colour] of [red, green, blue].entries()) {
  const spriteImage = ImageSprites[index + 1];
  adjustSpriteColour(colour, buff, imageToDraw, spriteImage);
}

With regards the colout/hue change, I haven’t done much work with canvas. One idea and maybe a long shot, but is CSS filter an option?

Edit: I have just noticed this thread is over a month old, so I am a bit late with these comments.