0

If you open Drawasaurus and create a closed figure on the canvas board and fill in that figure, you can see that it happens instantly. But when I try to implement the fill algorithm, it takes over 2-3 seconds to even fill a 200 by 200 pixel grid.

Is the implementation on Drawasaurus, different than flood fill? I am assuming that they might have divided their canvas in bigger grids of pixels. Instead of 1x1, maybe 10x10? And hence, that is more faster? Not sure though.

Need your help in understanding different approaches for filling a closed figure in canvas and how I can optimize it.

Here is my implementation:


import {colorToRgba, RGBA} from "./utils";

const { coordinates: c, color } = event;
const canvasWidth = canvasCtx!.width;

// Initial pixel information for the entire canvas. Doing it this way because invoking
// getImageData several times is expensive than just doing it once
const canvasPixelData = canvasCtx!.getImageData(0, 0, canvasCtx!.width, canvasCtx!.height);

// returns the indices for the r,g,b,a components of the colors of the provided pixel coordinates
const getColorIndicesForPixel = (
  x: number,
  y: number
): { rIndex: number; gIndex: number; bIndex: number; aIndex: number } => {
  const rIndex = y * (canvasWidth * 4) + x * 4;
  const [gIndex, bIndex, aIndex] = [rIndex + 1, rIndex + 2, rIndex + 3];

  return { rIndex, gIndex, bIndex, aIndex };
};

const getColorForPixel = (x: number, y: number) => {
  const { rIndex, gIndex, bIndex, aIndex } = getColorIndicesForPixel(x, y);
  const r = canvasPixelData.data[rIndex];
  const g = canvasPixelData.data[gIndex];
  const b = canvasPixelData.data[bIndex];
  const a = canvasPixelData.data[aIndex];
  return `rgba(${r}, ${g}, ${b}, ${a / 255})`;
};

// Color of the starting pixel
const initialColor = getColorForPixel(c.x, c.y);

// set of all the nodes that are already visited
const visitedNodes = new Set<string>();
console.log('reached here');

// Implementation of BFS algorithm for filling colors in
// the region
const colorFillAlgo = (x: number, y: number, newColor: RGBA) => {
  const pixelIndices = getColorIndicesForPixel(x, y);

  console.log('filling', x, y);

  // if index out of bounds, return
  if (x < 0 || x >= canvasCtx!.width || y < 0 || y >= canvasCtx!.height) return;

  // if color is not same as initial color, return
  if (getColorForPixel(x, y) !== initialColor) return;

  // if already visited, return
  if (visitedNodes.has(`${[x, y]}`)) return;

  // Add to visited nodes
  visitedNodes.add(`${[x, y]}`);

  // update the color in pixel data
  canvasPixelData.data[pixelIndices.rIndex] = newColor.r;
  canvasPixelData.data[pixelIndices.gIndex] = newColor.g;
  canvasPixelData.data[pixelIndices.bIndex] = newColor.b;
  canvasPixelData.data[pixelIndices.aIndex] = Math.floor(newColor.a * 255);

  // fill the surrounding nodes
  colorFillAlgo(x + 1, y, newColor);
  colorFillAlgo(x - 1, y, newColor);
  colorFillAlgo(x, y + 1, newColor);
  colorFillAlgo(x, y - 1, newColor);
};

// start the algorithm from starting point
colorFillAlgo(c.x, c.y, colorToRgba(color));

// update the canvas with the new color values
canvasCtx!.putImageData(canvasPixelData, 0, 0);

Edit 1 Following is after applying some optimizations as suggested

import { colorToRgba, RGBA } from "./pkg-utils";
import { not, Queue } from "@vighnesh153/utils";

const { coordinates: c, color } = event;
const canvasWidth = canvasCtx!.width;

// Initial pixel information for the entire canvas. Doing it this way because invoking
// getImageData several times is expensive than just doing it once
const canvasPixelData = canvasCtx!.getImageData(0, 0, canvasCtx!.width, canvasCtx!.height);

// returns the indices for the r,g,b,a components of the colors of the provided pixel coordinates
const getColorIndicesForPixel = (
  x: number,
  y: number
): { rIndex: number; gIndex: number; bIndex: number; aIndex: number } => {
  const rIndex = y * (canvasWidth * 4) + x * 4;
  const [gIndex, bIndex, aIndex] = [rIndex + 1, rIndex + 2, rIndex + 3];

  return { rIndex, gIndex, bIndex, aIndex };
};

const getColorForPixel = (x: number, y: number) => {
  const { rIndex, gIndex, bIndex, aIndex } = getColorIndicesForPixel(x, y);
  const r = canvasPixelData.data[rIndex];
  const g = canvasPixelData.data[gIndex];
  const b = canvasPixelData.data[bIndex];
  const a = canvasPixelData.data[aIndex];
  return { r, g, b, a };
};

const areColorsEqual = (color1: RGBA, color2: RGBA) => {
  if (color1.r !== color2.r) return false;
  if (color1.g !== color2.g) return false;
  if (color1.b !== color2.b) return false;
  if (color1.a !== color2.a) return false;
  return true;
};

// Color of the starting pixel
const initialColor = getColorForPixel(c.x, c.y);
const newColor = colorToRgba(color);

// In the pixel data, "alpha" needs to be between (0 and 255)
newColor.a *= 255;

// set of all the nodes that are already visited
const visitedNodes: Record<number, Record<number, boolean>> = {};
const pixelQueue = new Queue([c.x, c.y]);

// Implementation of BFS algorithm for filling colors in
// the region
while (not(pixelQueue.isEmpty)) {
  const [x, y] = pixelQueue.popLeft()!;

  const { rIndex, gIndex, bIndex, aIndex } = getColorIndicesForPixel(x, y);

  // if index out of bounds, return
  if (x < 0 || x >= canvasCtx!.width || y < 0 || y >= canvasCtx!.height) continue;

  // if color is not same as initial color, return
  if (not(areColorsEqual(getColorForPixel(x, y), initialColor))) continue;

  // if already visited, return
  if (visitedNodes[x]?.[y]) continue;

  // Add to visited nodes
  visitedNodes[x] = visitedNodes[x] || ({} as Record<number, boolean>);
  visitedNodes[x][y] = true;

  // update the color in pixel data
  canvasPixelData.data[rIndex] = newColor.r;
  canvasPixelData.data[gIndex] = newColor.g;
  canvasPixelData.data[bIndex] = newColor.b;
  canvasPixelData.data[aIndex] = newColor.a;

  // fill the surrounding nodes
  pixelQueue.pushRight([x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]);
}

// update the canvas with the new color values
canvasCtx!.putImageData(canvasPixelData, 0, 0);
vighnesh153
  • 4,354
  • 2
  • 13
  • 27
  • Have you looked at the source code for that page? If I had to guess, I'd guess they're not doing the drawing in HTML5. Since it is multiplayer, all of the drawing commands have to go to a server anyway, where they probably draw into a bitmap and use that to update the display. – Tim Roberts Mar 13 '22 at 06:50
  • In the developer tools, I saw the network tab and observed that they just send some events via web-sockets like `{ type: 'fill', x: ..., y: ... }`. So, I think that must be happening on browser itself. Reverse engineering their minified code would be difficult, so didn't try that. – vighnesh153 Mar 13 '22 at 06:53
  • That implies they are doing the edge detections on the server and just sending over the fills. – Tim Roberts Mar 13 '22 at 07:12
  • Creating strings for the colors is inefficient. Comparing just the bytes using or would allow short-circuiting. Using a stack instead of recursion should speed it up. If you're counting speed when doing `console.log` that will slow it down too, but I assume that's just for debugging... – Jason Goemaat Mar 13 '22 at 08:39
  • You are right. `console.log` was slowing down by a lot. I removed it and also, applied some of the optimizations like you suggested. Now, it is significantly faster, but if I compare it to `drawasaurus`, it is still slower by about 2-3 times. – vighnesh153 Mar 13 '22 at 10:02

0 Answers0