1
votes

I'm trying to make a pixel editor with 2 canvas. The first canvas displays a second canvas which contains the pixels. The first canvas uses drawImage to position and scale the second canvas.

When the second canvas is scaled smaller than it's original size, it starts to glitch.

Here is the canvas displayed at it's original size. When I zoom in, the second canvas get bigger and everything works perfectly.

Normal scale

However when I zoom out, the grid and the background (transparency) act very strangely.

Smaller scale

Even smaller scale

To draw the second canvas on the first canvas, I use the function

ctx.drawImage(drawCanvas, offset.x, offset.y, width * pixelSize, height * pixelSize);

I have read that scaling in multiple iterations might give a better quality with images but I am not sure about a canvas.

I could fully redraw the second canvas in a lower resolution when the user zooms out, but it is a bit heavy on the cpu.

Is there any better solution that I don't know of?

1
For now I hide the grid when the size of one pixel of the image is smaller than 14px and I hide the transparency background (grey and white squares) when the size of one pixel is smaller than 4px.Elbbard

1 Answers

4
votes

Your problem comes from anti-aliasing.

Pixels aren't sub-divisible, and when you ask the computer to draw something outside of the pixel boundaries, it will try its best to render something that usually looks good to eyes, by mixing the colors so that what should have been a black 0.1 pixel line will become a light-gray pixel for instance.

This generally works good, particularly with pictures of the real word, or complex shapes like circles. However with grids... That's not so great as you experienced it.

Your case is dealing with two different cases, and you will have to deal with hem separately.

  • In the canvas 2D API (and a lot of 2D APIs) stroke do bleed from both sides of the coordinates you did set it. So when drawing lines of 1px wide, you need to account for a 0.5px offset to be sure it won't get rendered as two gray pixels. For more info about this, see this answer. You are probably using such a stroke for the grid.

  • fill on the other hand only covers the inside of the shape, so if you fill a rectangle, you need to not offset its coords from the px boundaries. This is required for the checkerboard.

Now, for boh these drawings, the best is probably to use patterns. You only need to draw a small version of it, and then the pattern will repeat it automatically, saving a lot of computation.

Scaling of a pattern can be done by calling the transform methods of the 2D context. We can even take advantage of the closest-neighbor algorithm to avoid antialising when drawing this pattern by setting the imageSmoothingEnabled property to false.

However for our grid, we may want to keep the lineWidth constant. For this we will need to generate a new pattern at every draw call.

// An helper function to create CanvasPatterns
// returns a 2DContext on which a simple `finalize` method is attached
// method which does return a CanvasPattern from the underlying canvas
function patternMaker(width, height) {
  const canvas = document.createElement('canvas');
  canvas.width = width;
  canvas.height = height;
  const ctx = canvas.getContext('2d');
  ctx.finalize = (repetition = "repeat") => ctx.createPattern(canvas, repetition);
  return ctx;
}

// The checkerboard can be generated only once
const checkerboard_patt_maker = patternMaker(2, 2);
checkerboard_patt_maker.fillStyle = "#CCC";
checkerboard_patt_maker.fillRect(0,0,1,1);
checkerboard_patt_maker.fillRect(1,1,1,1);
const checkerboard_patt = checkerboard_patt_maker.finalize();

// An helper function to create grid patterns
// Since we want a constant lineWidth, no matter the zoom level
function makeGridPattern(width, height) {
  width = Math.round(width);
  height = Math.round(height);
  const grid_patt_maker = patternMaker(width, height);
  grid_patt_maker.lineWidth = 1;
  // apply the 0.5 offset only if we are on integer coords
  // for instance a <3,3> pattern wouldn't need any offset, 1.5 is already perfect
  const x = width/2 % 1 ? width/2 : width/2 + 0.5;
  const y = height/2 % 1 ? height/2 : height/2 + 0.5;
  grid_patt_maker.moveTo(x, 0);
  grid_patt_maker.lineTo(x, height);
  grid_patt_maker.moveTo(0, y);
  grid_patt_maker.lineTo(width, y);
  grid_patt_maker.stroke();
  return grid_patt_maker.finalize();
}

const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const checkerboard_input = document.getElementById('checkerboard_input');
const grid_input = document.getElementById('grid_input');
const connector = document.getElementById('connector');

function draw() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  const checkerboard_zoom = checkerboard_input.value;
  const grid_zoom = grid_input.value;
  // we generate a new pattern for the grid, so the lineWidth is always 1
  const grid_patt = makeGridPattern(grid_zoom,  grid_zoom);

  // draw once the rectangle covering the whole canvas
  // with normal transforms
  ctx.beginPath();
  ctx.rect(0, 0, canvas.width, canvas.height);

  // the checkerboard
  ctx.fillStyle = checkerboard_patt;
  // our path is already drawn, we can control only the fill
  ctx.scale(checkerboard_zoom, checkerboard_zoom);
  // avoid antialiasing when painting our pattern (similar to rounding the zoom level)
  ctx.imageSmoothingEnabled = false;
  ctx.fill();
  // done, reset to normal
  ctx.imageSmoothingEnabled = true;
  ctx.setTransform(1, 0, 0, 1, 0, 0);

  // paint the grid
  ctx.fillStyle = grid_patt;
  // because our grid is drawn in the middle of the pattern
  ctx.translate(Math.round(grid_zoom/2), Math.round(grid_zoom/2));
  ctx.fill();
  // reset
  ctx.setTransform(1, 0, 0, 1, 0, 0);
}
draw();

checkerboard_input.oninput = grid_input.oninput = function(e) {
  if(connector.checked) {
    checkerboard_input.value = grid_input.value = this.value;
  }
  draw();
};
connector.oninput = e => checkerboard_input.oninput();
<label>checkerboard-layer zoom<input id="checkerboard_input" type="range" min="2" max="50" step="0.1"></label><br>
<label>grid-layer zoom<input id="grid_input" type="range" min="2" max="50" step="1"></label><br>
<label>connect both zooms<input id="connector" type="checkbox"></label>
<canvas id="canvas"></canvas>