Mad Scientist
Mad Scientist

Reputation: 18553

Drawing crisp 1px lines on HTML5 canvas when using transformations

I'm drawing a lot of 1px lines on an HTML5 canvas element in my code. The drawing code looks roughly like the following, the transform variable in this case is set using d3-zoom. instructions.f32 is a Float32Array that contains the coordinates I use for drawing the lines.

 context.setTransform(
    transform.k,
    0,
    0,
    transform.k,
    transform.x,
    transform.y
  );
  context.lineWidth = 1 / transform.k;
  context.beginPath();
  for (let i = from; i < to; ++i) {

    let v1 = instructions.f32[i * 4 + 1];
    let v2 = instructions.f32[i * 4 + 2];

    // execute some moveTo/lineTo commands using v1 and v2 as coordinates
  }
  context.stroke();

One issue with this code is that the 1px lines are blurry because I'm drawing across pixel boundaries. I tried to adapt the code to snap the lines to the nearest pixels like the following:

let v1 = (Math.round(instructions.f32[i * 4 + 1] * transform.k) + 0.5) / transform.k;
let v2 = (Math.round(instructions.f32[i * 4 + 2] * transform.k) + 0.5) / transform.k;

But that still results in blurry lines like the following image (screenshot of a zoomed in image):

enter image description here

If I didn't have any transformation set, as far as I understand I would simply have to round the coordinates to the nearest pixel and add 0.5 to get crisp lines. But I'm not sure how to achieve this when my entire canvas is transformed, and I'm not drawing into the final coordinate system. As my attempts to correct for this have failed so far, it looks like I'm missing something here, or making some other mistake on the way.

How can I draw crisp 1px lines in canvas when transforming my entire canvas with setTransform? How exactly do I have to round the coordinates to snap the resulting lines to pixels?

Upvotes: 0

Views: 1209

Answers (1)

Kaiido
Kaiido

Reputation: 136698

Since it seems your transform doesn't have a skew or rotate property, the easiest will probably be to not transform your context, but rather scale and translate all the coordinates.

Currently, you are setting the lineWidth to 1 / zoom, given how compiters are good with Math precision, you will have hard time ending up drawing a perfect 1px stroke with that, only a few zoom values will, and if you wish to restrain your zoom to these values, you'll get choppy zoom.

Instead always keep the lineWidth at 1px, scale and translate all coords, before rounding these to the nearest pixel boundary.

context.setTransform(1,0,0,1,0,0);
context.lineWidth = 1;
context.beginPath();
for (let i = from; i < to; ++i) {

  let v1 = instructions.f32[i * 4 + 1];
  let v2 = instructions.f32[i * 4 + 2];
  // scale and translate
  v1 = (v1 + transform.x) * transform.k;
  v2 = (v2 + transform.y) * transfrom.k;
  // round
  const r1 = Math.round(v1);
  const r2 = Math.round(v2);
  // to nearest px boundary
  v1 = r1 + (0.5 * Math.sign(r1 - v1) || 0.5);
  v2 = r2 + (0.5 * Math.sign(r2 - v2) || 0.5);

  // lineTo...
}

const pts = [60, 60, 60, 110, 100,110, 100, 90, 220, 90];
const zoom = d3.behavior.zoom()
    .scaleExtent([1, 10])
    .on("zoom", zoomed);
const transform = {k: 1, x: 0, y: 0};
const context = canvas.getContext('2d');
d3.select('canvas')
  .call(zoom);
draw();
function draw() {

    context.setTransform(1,0,0,1,0,0);
    context.clearRect(0,0,canvas.width, canvas.height);
    context.lineWidth = 1;
    context.beginPath();
    for (let i = 0; i < pts.length; i+=2) {

      let v1 = pts[i];
      let v2 = pts[i + 1];
      // scale and translate
      v1 = (v1 + transform.x) * transform.k;
      v2 = (v2 + transform.y) * transform.k;
      // round
      const r1 = Math.round(v1);
      const r2 = Math.round(v2);
      // to nearest px boundary
      v1 = r1 + (0.5 * Math.sign(r1 - v1) || 0.5);
      v2 = r2 + (0.5 * Math.sign(r2 - v2) || 0.5);

      context.lineTo(v1, v2);
    }
    context.stroke();
}
function zoomed() {
  const evt = d3.event;
  transform.k = evt.scale;
  transform.x = evt.translate[0];
  transform.y = evt.translate[1];
  draw();
}
canvas {border: 1px solid}
zoom with mousewheel and pan by dragging<br>
<canvas id="canvas"></canvas>
<script src="//d3js.org/d3.v3.min.js"></script>

But you may prefer less precise but also less jagged and simpler flooring:

  v1 = (v1 + transform.x) * transform.k;
  v2 = (v2 + transform.y) * transform.k;
  // floor
  v1 = Math.floor(v1) + 0.5;
  v2 = Math.floor(v2) + 0.5;

  // lineTo

const pts = [60, 60, 60, 110, 100,110, 100, 90, 220, 90];
const zoom = d3.behavior.zoom()
    .scaleExtent([1, 10])
    .on("zoom", zoomed);
const transform = {k: 1, x: 0, y: 0};
const context = canvas.getContext('2d');
d3.select('canvas')
  .call(zoom);
draw();
function draw() {

    context.setTransform(1,0,0,1,0,0);
    context.clearRect(0,0,canvas.width, canvas.height);
    context.lineWidth = 1;
    context.beginPath();
    for (let i = 0; i < pts.length; i+=2) {

      let v1 = pts[i];
      let v2 = pts[i + 1];
      // scale and translate
      v1 = (v1 + transform.x) * transform.k;
      v2 = (v2 + transform.y) * transform.k;
      // floor
      v1 = Math.floor(v1) + 0.5;
      v2 = Math.floor(v2) + 0.5;
      context.lineTo(v1, v2);
    }
    context.stroke();
}
function zoomed() {
  const evt = d3.event;
  transform.k = evt.scale;
  transform.x = evt.translate[0];
  transform.y = evt.translate[1];
  draw();
}
canvas {border: 1px solid}
zoom with mousewheel and pan by dragging<br>
<canvas id="canvas"></canvas>
<script src="//d3js.org/d3.v3.min.js"></script>

Upvotes: 2

Related Questions