Заметки про canvas

Содержание

Задачи на канвас попадаются нечасто, поэтому каждый раз приходится вспоминать ряд особенностей, без учёта которых картинка будет отличаться на разных устройствах.

Чтобы каждый раз не вспоминать что к чему, собрал несколько заметок, которые помогут уверенно стартовать при работе с канвасом.

Размер и масштаб

Допустим, нам нужно нарисовать квадрат 100x100 пикселей. Сделать это можно вот так:

const ctx = canvas.getContext("2d");
ctx.fillRect(25, 25, 100, 100);

Проблема

На старых экранах квадрат будет выглядеть хорошо. Но на современных мониторах изображение будет мылить. Почему?

Всё дело в плотности пикселей.

Канвас позволяет создавать растровую графику. И всё, что мы через него рисуем, становится частью пиксельной сетки.

Есть две категории пикселей:

  • физические — реальное количество пикселей на экране;
  • виртуальные (логические) — значения, которыми мы оперируем в разметке, например в CSS.

На экранах с плотностью пикселей 1x всё выглядит хорошо, так как количество виртуальных пикселей равно числу физических. На экранах с плотностью пикселей выше единицы физических пикселей больше, чем виртуальных, поэтому картинка будет выглядеть размытой.

Как исправить?

Необходимо учитывать плотность пикселей. В коде мы можем получить это значение из свойства window.devicePixelRatio.

Для начала необходимо задать размер холста с учётом плотности пикселей:

// желаемые размеры канваса
const width = 300;
const height = 150;

// получаем плотность пикселей
const scaleIndex = window.devicePixelRatio;

// выставляем размер холста
// с учётом плотности пикселей
canvas.width = width * scaleIndex;
canvas.height = height * scaleIndex;

// размер элемента задаём как обычно
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;

Далее, когда мы рисуем что-либо на холсте, нужно также учитывать плотность пикселей. Сделать это можно с помощью метода ctx.scale():

// получаем плотность пикселей
const scaleIndex = window.devicePixelRatio;

const ctx = canvas.getContext("2d");

// масштабируем холст с учётом плотности пикселей
ctx.scale(scaleIndex, scaleIndex);

// рисуем квадрат, как и делали это раньше
ctx.fillRect(25, 25, 100, 100);

Всё. Теперь наш квадрат будет выглядеть одинаково хорошо на экранах с разной плотностью пикселей.

Выглядит довольно просто, но когда впервые работаешь с канвасом этот момент как-то вообще не очевиден.

Скорость анимации

Допустим, нам нужно нарисовать круг на канвасе:

const x = 50;
const y = 50;
const radius = 20;

const drawCircle = () => {
    ctx.beginPath();
    ctx.arc(x, y, radius, 0, Math.PI * 2);
    ctx.fillStyle = 'black';
    ctx.fill();
};

И круг должен сдвигаться по горизонтали на 10 пикселей в единицу времени. Описать эту анимацию можно с помощью метода window.requestAnimationFrame:

let x = 50;
const y = 50;
const radius = 20;
const step = 10;

const drawCircle = () => {
    ctx.beginPath();
    ctx.arc(x, y, radius, 0, Math.PI * 2);
    ctx.fillStyle = 'black';
    ctx.fill();
};

const clearCanvas = () => {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
};

const animate = () => {
    x += step;

    clearCanvas();

    drawCircle();

    requestAnimationFrame(animate);
}

requestAnimationFrame(animate);

Проблема

Этот код будет работать. Круг будет передвигаться из одного края холста в другой. Всё прекрасно.

Но если вы попробуете открыть эту демку на другом экране, то можете увидеть, что скорость анимации отличается от ожидаемой.

Почему так может произойти?

Причина кроется в частоте обновления экрана.

На экранах с меньшей частотой обновления скорость анимации будет ниже. На экранах с большей частотой — выше.

Частота обновления влияет на количество вызовов requestAnimationFrame. Чем выше частота, тем чаще будет вызываться метод. Чем чаще вызывается метод, тем быстрей анимация.

Как исправить?

Чтобы избежать такой неопределённости, нужно учитывать прошедшее время между кадрами, а не просто рисовать каждый кадр в requestAnimationFrame:

let x = 50;
const y = 50;
const radius = 20;
const step = 10;

let lastTime = null;

const drawCircle = () => {
    ctx.beginPath();
    ctx.arc(x, y, radius, 0, Math.PI * 2);
    ctx.fillStyle = 'black';
    ctx.fill();
};

const clearCanvas = () => {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
};

const animate = (timestamp) => {
    if (!lastTime) lastTime = timestamp;

    // считаем дельту - сколько времени в секундах
    // прошло с предыдущего кадра
    const delta = (timestamp - lastTime) / 1000;

    lastTime = timestamp;

    // используем дельту в качестве коэффициента
    x += step * delta;

    clearCanvas();

    drawCircle();

    requestAnimationFrame(animate);
}

requestAnimationFrame(animate);

Теперь круг будет перемещаться с одинаковой скоростью на экранах с разной частотой обновления кадров.