0

I am trying to draw a rectangle on an HTML canvas with pixel-perfect positioning, irregardless of the translation and scale applied to the canvas transform (assume rotation isn't used in this scenario).

In this case, I always want the border of the rectangle to be 1px wide on the screen, irregardless of how zoomed in the rectangle is with the scale applied (which I have working in the demo below), however I also want it drawn at exact pixel coordinates so no antialiasing is applied.

In the code below, I'm pretty sure I have to manipulate the arguments of ctx.rect to add offsets to each position in order to round it to display exactly on the nearest pixel on screen. But I'm not sure what math to use to get there?

As you can see in this demo, with 1.5 scaling applied the rectangle is no longer drawn on pixel perfect coordinates.

const canvases = [
  {
    ctx: document.getElementById('canvasOriginal').getContext('2d'),
    scale: 1,
    translateX: 0,
    translateY: 0
  },
  {
    ctx: document.getElementById('canvasZoomed').getContext('2d'),
    scale: 1.5,
    translateX: -0.5,
    translateY: -0.5
  }
];

for (const { ctx, scale, translateX, translateY } of canvases) {
  ctx.translate(translateX, translateY);
  ctx.scale(scale, scale);
  ctx.beginPath();
  ctx.rect(1.5, 1.5, 4, 4);
  ctx.lineWidth = 1 / scale;
  ctx.strokeStyle = 'red';
  ctx.stroke();
}
canvas {
  border: 1px solid #ccc;
  image-rendering: pixelated;
  image-rendering: crisp-edges;
  width: 100px;
  height: 100px;
}
<canvas id="canvasOriginal" width="10" height="10"></canvas>
<canvas id="canvasZoomed" width="10" height="10"></canvas>

This is my desired result of the scaled image in the snippet above:

enter image description here

EDIT: Please do not ignore translation.

user2867288
  • 1,979
  • 16
  • 20

2 Answers2

1

The given answer has a lot of under the hood overhead, getting and creating a DOM matrix, creating a DOM point, transforming the point then inverting the matrix and transforming back is a literal 100+ multiplications and additions (ignoring the memory management overhead).

As you are only scaling and with no translation or rotation it can be done in one division, eight multiplies and eight additions/subtractions, and be at least an order of magnitude quicker.

Example

const canvases = [
  { ctx: document.getElementById('canvasOriginal').getContext('2d'), scale: 1 },
  { ctx: document.getElementById('canvasZoomed').getContext('2d'), scale: 1.5 },
];
function pathRectPixelAligned(ctx, scale, x, y, w, h) {
    const invScale = 1 / scale;
    x = (Math.round(x * scale) + 0.5) * invScale ;   
    y = (Math.round(y * scale) + 0.5) * invScale ;  
    w = (Math.round((x + w) * scale) - 0.5) * invScale - x;   
    h = (Math.round((y + h) * scale) - 0.5) * invScale - y; 
   ctx.rect(x, y, w, h);
}
for (const { ctx, scale } of canvases) {
  ctx.scale(scale, scale);
  ctx.beginPath();
  pathRectPixelAligned(ctx, scale, 1, 1, 4, 4)
  ctx.lineWidth = 1;
  ctx.strokeStyle = 'red';
  ctx.setTransform(1,0,0,1,0,0);
  ctx.stroke();
}
canvas {
  border: 1px solid #ccc;
  image-rendering: pixelated;
  image-rendering: crisp-edges;
  width: 100px;
  height: 100px;
}
<canvas id="canvasOriginal" width="10" height="10"></canvas>
<canvas id="canvasZoomed" width="10" height="10"></canvas>
Blindman67
  • 51,134
  • 11
  • 73
  • 136
  • Thanks for the performance tip, but this example breaks when I apply a non-integer translation to the transform matrix. I realize I was implicit about this in the question as I only stated to ignore rotation. https://jsfiddle.net/oe07xykq/ – user2867288 Mar 21 '21 at 15:16
  • I actual am applying translation, rotation, and scale. I just want it to look pixel perfect when the rotation is a multiple of 90 degrees. This is why I said ignore rotation, as pixel perfect doesn't matter in that scenario. But yeah, I never said to ignore translation. – user2867288 Mar 21 '21 at 17:59
0

Ok, I uh... figured it out myself. You can use the DOMPoint class to transform a coordinate through the canvas's transform matrix. So I wrote a function that transforms the point through the matrix, rounds it to the nearest half pixel (since a 1 pixel wide stroke is rendered at the center, e.g. half-point of a pixel), then transforms it back through the inverse of the matrix.

This results in rendering the scaled 1px stroke to the nearest pixel on screen.

Hopefully this question will be useful to others browsing the internet, as it took me forever to figure out this problem prior to posting this question...

const canvases = [
  {
    ctx: document.getElementById('canvasOriginal').getContext('2d'),
    scale: 1,
    translateX: 0,
    translateY: 0
  },
  {
    ctx: document.getElementById('canvasZoomed').getContext('2d'),
    scale: 1.5,
    translateX: -0.5,
    translateY: -0.5
  }
];

const roundPointToHalfIdentityCoordinates = (ctx, x, y) => {
  let point = new DOMPoint(x, y);
  point = point.matrixTransform(ctx.getTransform());
  point.x = Math.round(point.x - 0.5) + 0.5;
  point.y = Math.round(point.y - 0.5) + 0.5;
  point = point.matrixTransform(ctx.getTransform().inverse());
  return point;
};

for (const { ctx, scale, translateX, translateY } of canvases) {
  ctx.translate(translateX, translateY);
  ctx.scale(scale, scale);
  ctx.beginPath();
  const topLeft = roundPointToHalfIdentityCoordinates(ctx, 1.5, 1.5);
  const bottomRight = roundPointToHalfIdentityCoordinates(ctx, 5.5, 5.5);
  ctx.rect(
    topLeft.x,
    topLeft.y,
    bottomRight.x - topLeft.x,
    bottomRight.y - topLeft.y
  );
  ctx.lineWidth = 1 / scale;
  ctx.strokeStyle = 'red';
  ctx.stroke();
}
canvas {
  border: 1px solid #ccc;
  image-rendering: pixelated;
  image-rendering: crisp-edges;
  width: 100px;
  height: 100px;
}
<canvas id="canvasOriginal" width="10" height="10"></canvas>
<canvas id="canvasZoomed" width="10" height="10"></canvas>
user2867288
  • 1,979
  • 16
  • 20