javascriptcanvasfadetiles

Fade out tiles in canvas image


Still learning Canvas. How can I fade out each tile in an image that I have as Image Data in an array:

            for (let i = 0; i < tilesY; i++) {
                for (let j = 0; j < tilesX; j++) {
                    this.ctxTile.putImageData(this.tileData[itr], j * tileWidth, i * tileHeight);
                    itr += 1
                }
            }

I understand that the solution must have something to do with compositing? I want to fade out each tile individually. The putImageData works and the image is inside canvas, and assembled as a set of tiles.

Thanks


Solution

  • Usually you'd just play with the ctx.globalAlpha property at the time of drawing to your context to set your tile's alpha. However, putImageData is kind of a strange beast in the API in that it ignores the context transformation, clipping areas and in our case compositing rules, including globalAlpha.

    So one hack around would be to "erase" the given tile after we've drawn it. For this we can use the globalCompositeOperation = "destination-out" property that we'll use on a call to a simple fillRect() with the inverse globalAlpha we want. (Luckily putImageData always draws only rectangles).

    const canvas = document.querySelector("canvas");
    const ctx = canvas.getContext("2d");
    const tileWidth  = 50;
    const tileHeight = 50;
    class Tile {
      constructor(x, y, width, height) {
        this.x = Math.round(x); // putImageData only renders at integer coords
        this.y = Math.round(y);
        this.width = width;
        this.height = height;
        this.img = buildImageData(width, height);
        this.alpha = 1;
      }
      isPointInPath(x, y) {
        return x >= this.x && x <= this.x + this.width &&
               y >= this.y && y <= this.y + this.height;
      }
      draw() {
        ctx.putImageData(this.img, this.x, this.y);
        ctx.globalAlpha = 1 - this.alpha; // inverse alpha
        // the next drawing will basically erase what it represents
        ctx.globalCompositeOperation = "destination-out";
        ctx.fillRect(this.x, this.y, this.width, this.height);
        // restore the context
        ctx.globalAlpha = 1;
        ctx.globalCompositeOperation = "source-over";
      }
    }
    const tiles = Array.from({ length: 5 }, (_, i) => new Tile(i * tileWidth * 1.25, 0, tileWidth, tileHeight));
    
    canvas.onclick = (e) => {
      const x = e.clientX - canvas.offsetLeft;
      const y = e.clientY - canvas.offsetTop;
      const clickedTile = tiles.find((tile) => tile.isPointInPath(x, y));
      if (clickedTile) { clickedTile.alpha -= 0.1 };
      redraw();
    };
    redraw();
    
    function redraw() {
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      tiles.forEach((tile) => tile.draw());
    }
    function buildImageData(width, height) {
      const img = new ImageData(width, height);
      const arr = new Uint32Array(img.data.buffer);
      for (let i = 0; i < arr.length; i++) {
        arr[i] = Math.random() * 0xFFFFFF + 0xFF000000;
      }
      return img;
    }
    <p>Click on each tile to lower its alpha.</p>
    <canvas></canvas>

    However this means that for each tile we have one putImageData + one composited fillRect. If you've got a lot of tiles, that makes for a pretty big overhead.

    So instead the best might be to convert all your ImageData objects to ImageBitmap ones. To understand the difference between both I invite you to read this answer of mine.

    Once we have ImageBitmaps, we can apply the globalAlpha on our draw call directly:

    const canvas = document.querySelector("canvas");
    const ctx = canvas.getContext("2d");
    const tileWidth  = 50;
    const tileHeight = 50;
    class Tile {
      constructor(x, y, width, height) {
        this.x = Math.round(x); // putImageData only renders at integer coords
        this.y = Math.round(y);
        this.width = width;
        this.height = height;
        this.alpha = 1;
        const imgData = buildImageData(width, height);
        // createImageBitmap is "async"
        this.ready = createImageBitmap(imgData)
          .then((bmp) => this.img = bmp);
      }
      isPointInPath(x, y) {
        return x >= this.x && x <= this.x + this.width &&
               y >= this.y && y <= this.y + this.height;
      }
      draw() {
        // single draw per tile
        ctx.globalAlpha = this.alpha;
        ctx.drawImage(this.img, this.x, this.y, this.width, this.height);
        ctx.globalAlpha = 1;
      }
    }
    const tiles = Array.from({ length: 5 }, (_, i) => new Tile(i * tileWidth * 1.25, 0, tileWidth, tileHeight));
    
    canvas.onclick = (e) => {
      const x = e.clientX - canvas.offsetLeft;
      const y = e.clientY - canvas.offsetTop;
      const clickedTile = tiles.find((tile) => tile.isPointInPath(x, y));
      if (clickedTile) { clickedTile.alpha -= 0.1 };
      redraw();
    };
    // wait for all the ImageBitmaps are generated
    Promise.all(tiles.map((tile) => tile.ready)).then(redraw);
    
    function redraw() {
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      tiles.forEach((tile) => tile.draw());
    }
    function buildImageData(width, height) {
      const img = new ImageData(width, height);
      const arr = new Uint32Array(img.data.buffer);
      for (let i = 0; i < arr.length; i++) {
        arr[i] = Math.random() * 0xFFFFFF + 0xFF000000;
      }
      return img;
    }
    <p>Click on each tile to lower its alpha.</p>
    <canvas></canvas>