1

I'd like to have a smooth animation for simple pin with the text on it. This pin is moving slowly around the canvas and I need smooth animation. So my pin consists from the background (filled and shadowed circle) and a text on top of it.

I achieved very smooth movement for the circle itself, but not for the text!

Q: Is it possible to achieve smooth text movement in the HTML5 canvas and how?

What I tried:

Method 0: Just draw the circle, no text on it. Animation is smooth.
Problem: No problem at all, except there is no text.

Method 1: Draw text on top of the circle using canvas method fillText().
Problem: Text is jittering while moving vertically. Horizontal moving does not produce jittering.

Method 2: Draw text to the offscreen canvas, copy canvas as an image on top of the circle. Create offscreen canvas and draw the text on it with sizes twice bigger than the original and then shrink while copying to the screen canvas. This will sharpen the text.
Problem: Text is sharp, but wavy and there is some flickering appears during movement.

Method3: Draw text to the offscreen canvas, copy canvas as an image on top of the circle. Create offscreen canvas and draw the text there. Size of the canvas and the text is the same as on the screen.
Problem: Movement is smooth enough, but text is blurry, out of focus.

My JSFIDDLE: Canvas text jitter animation

My Javascript code:

var canvas = document.getElementById("canvas1");
var ctx = canvas.getContext("2d");

ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.font = "bold 16px Helvetica";
ctx.shadowOffsetX = ctx.shadowOffsetY = 2;
ctx.shadowBlur = 6;

var bgColor="blue";
var textColor="white";
var shadowColor="rgba(0, 0, 0, 0.4)";
var radius=15;

//Draw empty plate for the pin, no text on top
//Problem: NONE, movements are smooth
function drawPinPlate(x, y)
{
  var oldShadow = ctx.shadowColor;
  ctx.shadowColor = shadowColor;
  ctx.fillStyle = bgColor;
    ctx.beginPath();
  ctx.arc(x, y, radius, 0, 2*Math.PI);
  ctx.fill();
  ctx.shadowColor = oldShadow;
}

//method 1: Draw pin with text directly. 
//Draw text using canvas direct text rendering.
//Problem: Text vertical jittering while animating movement
function drawPin1(x, y, name)
{
    drawPinPlate(x, y);
  ctx.fillStyle = textColor;
  ctx.fillText(name, x, y);
}

//method 2: Draw pin with text using offscreen image with resize
//Draw text using text pre-rendered to offscreen canvas.
//Offscreen canvas is twice large than the original and we do resize (shrink) to the original one
//Problem: Text is sharp but some flickering appears during image movement
function drawPin2(x, y, name)
{
    drawPinPlate(x, y);
  ctx.drawImage(offImage1, x - radius, y - radius, radius*2, radius*2);
}

//method 2: Draw pin with text using offscreen image
//Draw text using text pre-rendered to offscreen canvas.
//Offscreen canvas is the same size as the original.
//Problem: Text is looking fuzzy, blurry
function drawPin3(x, y, name)
{
    drawPinPlate(x, y);
  ctx.drawImage(offImage2, x - radius, y - radius);
}


var PIXEL_RATIO = (function ()
{
    var ctx = document.createElement("canvas").getContext("2d"),
        dpr = window.devicePixelRatio || 1,
        bsr = ctx.webkitBackingStorePixelRatio ||
            ctx.mozBackingStorePixelRatio ||
            ctx.msBackingStorePixelRatio ||
            ctx.oBackingStorePixelRatio ||
            ctx.backingStorePixelRatio || 1;

    return dpr / bsr;
})();

//create offscreen canvas
createHiDPICanvas = function(w, h, ratio)
{
    if (!ratio) { ratio = PIXEL_RATIO; }
    var can = document.createElement("canvas");
    can.width = w * ratio;
    can.height = h * ratio;
    can.style.width = w + "px";
    can.style.height = h + "px";
    can.getContext("2d").setTransform(ratio, 0, 0, ratio, 0, 0);
    return can;
}

//create offscreen canvas with text, size of the canvas is twice larger than the original.
function createPin1(name)
{
    var cnv = createHiDPICanvas(radius*2, radius*2, 2);
  var ctx = cnv.getContext("2d");

    ctx.textAlign = "center";
  ctx.textBaseline = "middle";
  ctx.font = "bold 16px Helvetica";

  ctx.fillStyle = textColor;
  ctx.fillText(name, radius, radius);
  
  return cnv;
}

//create offscreen canvas with text. Text becomes very blurry.
function createPin2(name)
{
    var cnv = createHiDPICanvas(radius*2, radius*2, 1);
  var ctx = cnv.getContext("2d");

    ctx.textAlign = "center";
  ctx.textBaseline = "middle";
  ctx.font = "bold 16px Helvetica";

  ctx.fillStyle = textColor;
  ctx.fillText(name, radius, radius);
  
  return cnv;
}

var offImage1, offImage2;

offImage1 = createPin1("AB");
offImage2 = createPin2("AB");


var startTime;
var speed = 180;

//render one frame
function render(deltaTime)
{
    var x = (deltaTime / speed / 2) %100;
  var y = (deltaTime / speed) % 200;
  
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  
  for(var i = 0; i<4; i++)
  {
    ctx.fillText(i, 20 + x + i * 50, 15 + y);
  }
  
  drawPinPlate(20 + x, 40 + y);
  drawPin1(70 + x, 40 + y, "AB");
  drawPin2(120 + x, 40 + y, "AB");
  drawPin3(170 + x, 40 + y, "AB");
}

//Animation loop
function animate()
{
    requestAnimationFrame(animate);
  render(Date.now() - startTime);
}

//alert("You screen pixel ratio = " + PIXEL_RATIO);

startTime = Date.now();
animate();
Community
  • 1
  • 1
Northern Captain
  • 1,147
  • 3
  • 25
  • 32

3 Answers3

2

You can achieve smooth movement of text, by using your third method a.k.a the offscreen canvas.
However, it is not really possible to achieve smooth text rendering on canvas.

This is because texts are usually rendered with smart smoothing algorithms, at sub-pixel level and that canvas doesn't have access to such sub-pixel level, so it leaves us with blurry texts as a best.

Of course, you could try to implement a font-rasterization algo by yourself, SO user Blindman67 provided a good explanation along with a trying here, but it depends on so much parameters (which device, UA, font etc.) that making it on a moving target is almost a no-go.

So if you need perfect text rendering, on animated content, SVG is your friend. But even in SVG, text animation looks somewhat choppy.

text{
  font: bold 16px Helvetica;
  fill: white;
  }
<svg>
  <g id="group">
  <circle fill="blue" cx="15" cy="15" r="15"/>
  <text y="20" x="15" text-anchor="middle">AB
  </text>
    <animateTransform attributeName="transform"
                          attributeType="XML"
                          type="translate"
                          from="0 0"
                          to="80 150"
                          dur="20s"
                          repeatCount="indefinite"/>
  </g>
</svg>

Otherwise, for canvas, the best you could get would be to double the size of your canvas, and scale it down with CSS after-ward, using the offscreen canvas method.

var canvas = document.getElementById("canvas1");
var ctx = canvas.getContext("2d");

var scale = devicePixelRatio *2;
canvas.width *= scale;
canvas.height *= scale;

function initTextsSprites(texts, radius, padding, bgColor, textColor, shadowColor) {
  // create an offscreen canvas which will be used as a spritesheet
  var canvas = document.createElement('canvas');
  var ctx = canvas.getContext('2d');
  radius *= scale;
  padding *= scale;
  
  var d = radius * 2;

  var cw = (d + (padding * 2));
  canvas.width = cw * texts.length;
  canvas.height = d * 2 + padding * 2;

  var topAlignText = 6 * scale; // just because I don't trust textBaseline
  var y;

  //  drawCircles
  ctx.fillStyle = bgColor;
  ctx.shadowOffsetX = ctx.shadowOffsetY = 2;
  ctx.shadowBlur = 6;
  ctx.shadowColor = shadowColor;
  y = (radius * 2) + padding;
  ctx.beginPath();
  texts.forEach(function(t, i) {
    var cx = cw * i + padding * 2;
    ctx.moveTo(cx + radius, y)
    ctx.arc(cx, y, radius, 0, Math.PI * 2);
  })
  ctx.fill();

  // drawBlueTexts
  ctx.textAlign = "center";
  ctx.font = "bold "+(16 * scale)+"px Helvetica";
  ctx.shadowOffsetX = ctx.shadowOffsetY = ctx.shadowBlur = 0;
  y = padding + topAlignText;
  texts.forEach(function(txt, i) {
    var cx = cw * i + padding * 2;
    ctx.fillText(i, cx, y);
  });

  //  drawWhiteTexts
  ctx.fillStyle = 'white';
  var cy = (radius * 2) + padding + topAlignText;
  texts.forEach(function(txt, i) {
    var cx = cw * i + padding * 2;
    ctx.fillText(txt, cx, cy);
  });

  return function(index, x, y, w, h) {
    if (!w) {
      w = cw;
    }
    if (!h) {
      h = canvas.height;
    }
    // return an Array that we will able to apply on drawImage
    return [canvas,
      index * cw, 0, cw, canvas.height, // source
      x, y, w, h // destination
    ];
  };
}

var texts = ['', 'AA', 'AB', 'AC'];

var getTextSprite = initTextsSprites(texts, 15, 12, "blue", "white", "rgba(0, 0, 0, 0.4)");
// just to make them move independently
var objs = texts.map(function(txt) {
  return {
    x: Math.random() * canvas.width,
    y: Math.random() * canvas.height,
    speedX: Math.random() - .5,
    speedY: Math.random() - .5,
    update: function() {
      this.x += this.speedX;
      this.y += this.speedY;
      if (this.x < 0) {
        this.speedX *= -1;
        this.x = 0;
      }
      if (this.y < 0) {
        this.speedY *= -1;
        this.y = 0;
      }
      if (this.x > canvas.width) {
        this.speedX *= -1;
        this.x = canvas.width;
      }
      if (this.y > canvas.height) {
        this.speedY *= -1;
        this.y = canvas.height;
      }
    }
  }
});

function anim() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);

  objs.forEach(function(o, i) {
    o.update();
    ctx.drawImage.apply(ctx, getTextSprite(i, o.x, o.y));
  })
  requestAnimationFrame(anim);
}
anim();
#canvas1 {
  border: 1px solid;
  width: 500px;
  height: 300px;
}
<canvas id="canvas1" width="500" height="300"></canvas>
Community
  • 1
  • 1
Kaiido
  • 123,334
  • 13
  • 219
  • 285
  • Sorry, but I can't see the difference between your method and my method 3. Technique is the same - create offscreen canvas, draw the text there with the same size and then do copy this canvas to the onscreen one. The text on the screen is still blurry as I mentioned for method 3. – Northern Captain Apr 23 '17 at 19:34
  • @NorthernCaptain, you're completely right, I misread your question, sorry. I Tried to edit it so it asnwers it better. – Kaiido Apr 24 '17 at 06:28
  • Kaiido, thank you for the effort, I made the same conclusion to draw to the offscreen canvas with doubled size and then shrink down while copying to the canvas. That's my method 2 in the question ;) unfortunately it has problems too. – Northern Captain Apr 27 '17 at 16:39
  • @NorthernCaptain In this answer I am not shrinking when drawing on the visible canvas, the visible canvas itself is upscaled too, and is the only one down scaled, but by CSS (so that high dpi monitors have enough pjxels to render correctly) – Kaiido Apr 27 '17 at 23:00
  • Aha, I see, thanks for pointing that out. This means that I should have everything double sized to draw on this enormous canvas as I have not only these pins but other objects like sprite images. This will increase loading time, CPU usage and degrade performance on slow machines if the canvas is large enough. – Northern Captain Apr 28 '17 at 07:29
  • @NorthernCaptain, that's about it, except that it will increase GPU usage, not necessarily CPU one. Double sized canvas is a common trick when you want graphic quality, and like everywhere, you need to make a choice between quality and performances. But if perfs are really tied, one could make a perf test while running and reduce the global scale accordingly. – Kaiido Apr 28 '17 at 07:37
  • Thank you, I accepted your answer but I still think that there is no good solution for the case. – Northern Captain Apr 28 '17 at 20:39
  • The method i described below fixes the issue without super sampling having the cost of a 4k resolution at fullscreen. The rotation applied can be below a degree, when using a custom text-layout this can be even reduced to words or letters to avoid rotation to become visible at some point. – Fyrestar Sep 01 '20 at 23:45
1

It's because of this:

var radius = 15;

var cnv = createHiDPICanvas(radius*2, radius*2, 2);
var ctx = cnv.getContext("2d");

When you increase the size of the canvas you're creating to createHiDPICanvas(2000,2000, ...), the movement smoothes out. Right now you're creating a very small resolution canvas (30px by 30px), and the text looks jittery because it's moving across a very small range of pixels.

Example: https://jsfiddle.net/6xr4njLm/

Longer explanation of createHiDPICanvas: How do I fix blurry text in my HTML5 canvas?

Community
  • 1
  • 1
  • What if I need to create hundred of such pins to display and each will allocate canvas 3000x3000. What about memory consumption? – Northern Captain Apr 21 '17 at 18:27
  • Also I don't understand why does fillText produce vertical jittering, but does well in horizontal movement? – Northern Captain Apr 21 '17 at 18:35
  • In your example I set back speed value to 180 to achieve slow and smooth motion as required in my task and see absolutely no difference between your canvas 3000x3000 and mine 30x30... – Northern Captain Apr 21 '17 at 19:31
0

After some tests i've found out after applying a rotation of 1 degree the issue seems to disappear mostly if not completely. However for longer text rendering fractions might be required then. It seems to improve till 0.1 degree, anything lower the issue becomes clearly visible again.

Adding a rotation of 1 degree in your example before rendering the text such as:

https://jsfiddle.net/Fyrestar/c26u83vh/

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

let x = 0;

function render() {


  requestAnimationFrame( render );
  
  x += 0.01;
  
  const t =  0.2 + Math.abs( Math.sin( x ) ) * 0.8;
  
  ctx.clearRect( 0, 0, canvas.width, canvas.height );

  ctx.font = '40px Arial';
  
  ctx.save();
  ctx.scale( t, t );
  ctx.fillText( 'Hello World', 0 , 30 );
  ctx.restore();
  
  
  ctx.save();
  ctx.scale( t, t );
  ctx.rotate( Math.PI / 180 );
  ctx.fillText( 'Hello World', 0 , 60 );
  ctx.restore();

}

render();
<canvas id="canvas"></canvas>
Fyrestar
  • 84
  • 1
  • 7