Производительность анимаций: requestAnimationFrame и Canvas

Визуальные обновления в браузере подчиняются строгому циклу перерисовки, где ключевую роль играет синхронизация вычислений с кадрами отрисовки. Основной механизм, обеспечивающий стабильную частоту обновлений без перегрузки главного потока, — requestAnimationFrame.

Он выравнивает выполнение анимационных шагов с частотой обновления экрана, обычно 60 FPS, и предотвращает избыточные вызовы рендера, характерные для setInterval и setTimeout.


requestAnimationFrame как базовый цикл анимации

Функция requestAnimationFrame принимает callback, который вызывается перед следующим перерисовыванием экрана. Это позволяет строить предсказуемый и производительный цикл анимации.

Базовая структура:

function tick(timestamp) {
  // вычисления состояния сцены
  update(timestamp);

  // отрисовка
  render();

  requestAnimationFrame(tick);
}

requestAnimationFrame(tick);

Ключевая особенность — браузер сам регулирует частоту вызовов. При неактивной вкладке цикл замедляется, что снижает потребление ресурсов.


D3.js и управление кадрами

D3 предоставляет собственные инструменты управления временем и анимациями, среди которых:

  • d3.transition()
  • d3.timer()
  • d3.interpolate()

Однако при сложной визуализации с Canvas или гибридной архитектуре часто требуется ручной контроль через requestAnimationFrame.

d3.timer является обёрткой над requestAnimationFrame, добавляющей удобство управления временем:

import { timer } from "d3-timer";

timer((elapsed) => {
  update(elapsed);
  render();
});

Различие между transition и ручным RAF-циклом

d3.transition() оптимизирован для DOM/SVG и управляет интерполяцией свойств автоматически:

d3.select("circle")
  .transition()
  .duration(1000)
  .attr("r", 50);

Но при высокочастотной анимации (частицы, физические симуляции, графы с тысячами узлов) DOM становится узким местом:

  • каждый attr вызывает перерасчёт layout
  • SVG элементы создают значительную нагрузку на рендерер
  • большое количество узлов приводит к падению FPS

В таких случаях Canvas + RAF становится предпочтительным решением.


Canvas как высокопроизводительный слой отрисовки

Canvas работает по принципу растровой перерисовки: каждый кадр полностью перезаписывает содержимое области.

Базовый цикл:

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

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

  drawScene(ctx);

  requestAnimationFrame(renderFrame);
}

requestAnimationFrame(renderFrame);

Преимущество подхода — отсутствие затрат на управление DOM-деревом.


Поток данных: вычисление отдельно от рендера

Производительные анимации разделяют:

  • симуляцию состояния
  • отрисовку

D3-сила (force simulation) часто используется именно так:

const simulation = d3.forceSimulation(nodes)
  .force("charge", d3.forceManyBody())
  .force("center", d3.forceCenter(width / 2, height / 2))
  .on("tick", render);

При Canvas-рендеринге вместо обновления DOM выполняется перерисовка сцены:

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

  for (const node of nodes) {
    ctx.beginPath();
    ctx.arc(node.x, node.y, 3, 0, Math.PI * 2);
    ctx.fill();
  }
}

d3.timer и управление временем

d3.timer позволяет строить анимации без явного вызова requestAnimationFrame, сохраняя контроль над временем:

import { timer } from "d3-timer";

timer((elapsed) => {
  const t = elapsed / 1000;

  nodes.forEach(n => {
    n.x += Math.cos(t + n.id);
    n.y += Math.sin(t + n.id);
  });

  render();
});

Параметр elapsed обеспечивает стабильную временную шкалу, что важно для детерминированных симуляций.


Интерполяция и сглаживание движения

D3 использует систему интерполяции значений через d3-interpolate. Это позволяет плавно переходить между состояниями без ручного вычисления шагов.

Пример линейной интерполяции:

const i = d3.interpolateNumber(0, 100);

console.log(i(0.5)); // 50

В RAF-цикле интерполяция часто применяется для управления анимацией вручную:

const start = performance.now();
const duration = 1000;

function animate(now) {
  const t = Math.min(1, (now - start) / duration);

  const value = d3.interpolateNumber(0, 200)(t);

  ctx.clearRect(0, 0, width, height);
  ctx.fillRect(10, 10, value, 20);

  if (t < 1) requestAnimationFrame(animate);
}

requestAnimationFrame(animate);

Узкие места SVG при высокой частоте обновлений

SVG-подход удобен, но имеет ограничения при масштабировании:

  • каждое изменение атрибута вызывает перерасчёт представления
  • большое количество элементов приводит к росту затрат на layout и paint
  • сложные фильтры и эффекты резко снижают FPS

Canvas лишён этих проблем, но требует ручного управления сценой.


Батчинг и минимизация перерисовок

Ключевая оптимизация в RAF-циклах — объединение операций отрисовки:

Плохой подход:

nodes.forEach(drawNode); // может вызывать лишние состояния ctx

Оптимизированный подход:

ctx.beginPath();

for (const node of nodes) {
  ctx.moveTo(node.x + 2, node.y);
  ctx.arc(node.x, node.y, 2, 0, Math.PI * 2);
}

ctx.fill();

Снижение количества вызовов fill, stroke и beginPath напрямую влияет на производительность.


Разделение вычислений и рендера

Производительные системы строятся по схеме:

  1. обновление состояния (physics, layout)
  2. запись результатов в массивы
  3. отрисовка одним проходом
function update() {
  for (const node of nodes) {
    node.vx *= 0.98;
    node.vy *= 0.98;

    node.x += node.vx;
    node.y += node.vy;
  }
}

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

  for (const node of nodes) {
    ctx.fillRect(node.x, node.y, 2, 2);
  }
}

Высокочастотные данные и ограничение FPS

При потоковых данных (например, телеметрия или симуляции) частота обновления может превышать 60 Hz. В таких случаях используется throttling внутри RAF:

let lastRender = 0;
const frameInterval = 1000 / 60;

function loop(now) {
  if (now - lastRender > frameInterval) {
    render();
    lastRender = now;
  }

  requestAnimationFrame(loop);
}

requestAnimationFrame(loop);

Canvas + D3 scales в связке

D3-scale остаются полезными даже в Canvas-рендеринге:

const x = d3.scaleLinear()
  .domain([0, 100])
  .range([0, width]);

const y = d3.scaleLinear()
  .domain([0, 100])
  .range([height, 0]);

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

  for (const d of data) {
    ctx.fillRect(x(d.x), y(d.y), 3, 3);
  }
}

Разделение логики масштабирования и отрисовки упрощает поддержку и повышает читаемость вычислительного слоя.


Управление памятью и GC в анимациях

Частые ошибки производительных анимаций:

  • создание новых объектов в каждом кадре
  • пересоздание массивов вместо переиспользования
  • утечки через неостановленные timer/raf циклы

Паттерн с переиспользованием объектов:

for (let i = 0; i < nodes.length; i++) {
  const n = nodes[i];
  n.x += n.vx;
  n.y += n.vy;
}

Избегание Array.map и filter внутри RAF снижает давление на сборщик мусора.


Гибридная архитектура SVG + Canvas

В сложных визуализациях часто используется гибрид:

  • Canvas — основная сцена (тысячи объектов)
  • SVG — интерактивный слой (подписи, tooltips)

Это позволяет сохранить интерактивность без потери производительности.


Контроль времени и стабильность анимаций

Критически важный аспект — использование timestamp, а не фиксированных шагов:

let last = performance.now();

function loop(now) {
  const dt = now - last;
  last = now;

  update(dt);
  render();

  requestAnimationFrame(loop);
}

Такой подход делает симуляции независимыми от FPS и аппаратной нагрузки.