Спрайтовые листы и покадровая анимация: атлас, опорная точка, частота

Коротко

Покадровая анимация — это атлас с кадрами, формула прямоугольника кадра по индексу, общая опорная точка и своя частота клипа. Если считать номер кадра по времени, а не по числу обновлений экрана, анимация одинаково идёт на мониторах 60 и 144 Гц. События кадров («шаг», «удар») нужно запускать для всех пройденных кадров, иначе просадка FPS их проглотит.

Содержание

  1. Спрайтовый лист: как найти прямоугольник кадра
  2. Опорная точка: почему персонаж дёргается и уезжает при развороте
  3. Частота клипа против частоты экрана
  4. События на кадрах: шаги, удары, звуки
  5. Таблица: параметры клипов персонажа
  6. Код на JavaScript: атлас, разворот и проигрыватель клипа
  7. Практический сценарий: готовим лист бега к игре
  8. Когда вам это не подойдёт

Спрайтовый лист: как найти прямоугольник кадра

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

столбец = индекс mod столбцов
строка  = floor(индекс / столбцов)
x = поле + столбец × (ширина_кадра + зазор)
y = поле + строка  × (высота_кадра + зазор)

На холсте браузера этот прямоугольник передают в 9-аргументную форму drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight): первые четыре числа после изображения выбирают область источника, последние четыре — место и размер на экране. Поле и зазор в 1–2 пикселя нужны, чтобы при масштабировании соседний кадр не «подмешивался» по краю; о чётких краях пиксель-арта при масштабе — отдельная тема.

Опорная точка: почему персонаж дёргается и уезжает при развороте

Опорная точка (pivot) — это точка кадра, которая совпадает с позицией объекта в мире; у всех кадров клипа она должна быть одна и та же. Если художник нарисовал кадры бега со смещением на 2–3 пикселя, а движок рисует кадр от левого верхнего угла, персонаж «пляшет» на месте. Если опорная точка не по центру фигуры, при отражении по горизонтали он прыгает в сторону.

Решение — хранить pivot в координатах кадра и при развороте отражать и его:

pivot_x при развороте = ширина_кадра − pivot_x
угол рисования = позиция − pivot

Для героя платформера pivot ставят у стоп: на нижней строке кадра, посередине между ногами. Хитбокс считается от той же точки, поэтому разворот не задвигает героя в стену.

Частота клипа против частоты экрана

Клип анимации идёт со своей частотой, а экран обновляется со своей, и связывать их нельзя. Если менять кадр «каждые 5 обновлений экрана», на мониторе 60 Гц бег идёт 12 к/с, а на 144 Гц — 28,8 к/с, то есть в 2,4 раза быстрее. Номер кадра нужно считать по накопленному времени:

номер = floor(время_клипа × частота_клипа)
кадр  = номер mod длина_клипа          (для зацикленного)
кадр  = min(номер, длина_клипа − 1)    (для однократного)

При накоплении времени шагами вида 1/144 с сумма может оказаться чуть меньше целого из-за погрешности дробей, поэтому в коде ниже к произведению добавлена поправка 1e-9.

События на кадрах: шаги, удары, звуки

Событие кадра — это метка «на кадре N произошло X», и запускать её нужно для каждого пройденного кадра, а не только для текущего. При 12 к/с кадр длится 83 мс; если обновление игры однажды заняло 250 мс, за него пройдут три кадра клипа, и кадр со звуком шага может оказаться в середине. Проигрыватель должен помнить номер последнего обработанного кадра и перебрать все номера до текущего.

Удары связывают с событием кадра «активный кадр»: урон включается на нём, а не в момент нажатия атаки. Так замах остаётся видимым, а попадание совпадает с картинкой.

Таблица: параметры клипов персонажа

Значения ниже — рабочие стартовые параметры для персонажа 48×48 пикселей, которые затем подбирают на глаз.

КлипКадровЧастота, к/сДлительность циклаПовторСобытия кадров
Бездействие460,67 сда—
Бег8120,67 сдашаг на 2 и 6
Прыжок вверх3120,25 снетпыль на 0
Атака6180,33 снетурон на 3, конец на 5
Получение урона2100,2 снет—

Длительность цикла считается как число кадров, делённое на частоту: 8 кадров при 12 к/с дают 0,67 с.

Код на JavaScript: атлас, разворот и проигрыватель клипа

Код считает прямоугольники кадров, точку рисования с учётом разворота и проигрывает клип бега на экранах 60 и 144 Гц. Он проверен в Node.js 24: числа в комментариях совпадают с выводом.

// Прямоугольник кадра в атласе по индексу: поля (margin) и зазоры (spacing) учитываются явно
function frameRect(index, sheet) {
  const col = index % sheet.columns;
  const row = Math.floor(index / sheet.columns);
  return {
    x: sheet.margin + col * (sheet.frameW + sheet.spacing),
    y: sheet.margin + row * (sheet.frameH + sheet.spacing),
    w: sheet.frameW,
    h: sheet.frameH,
  };
}

// Куда рисовать кадр, чтобы опорная точка (pivot) совпала с позицией объекта.
// flip = true — отражение по горизонтали: pivot тоже отражается внутри кадра.
function drawCorner(pos, sheet, flip) {
  const px = flip ? sheet.frameW - sheet.pivotX : sheet.pivotX;
  return { x: pos.x - px, y: pos.y - sheet.pivotY };
}

// Проигрыватель клипа: своя частота кадров, не зависит от частоты экрана.
// Возвращает события кадров, которые были пройдены за этот шаг (даже если кадр пропущен).
function createPlayer(clip) {
  let time = 0;
  let last = -1;
  return {
    update(dt) {
      time += dt;
      // 1e-9 гасит ошибку сложения дробей: 144 × (1/144) даёт чуть меньше 1
      const total = Math.floor(time * clip.fps + 1e-9);
      const frame = clip.loop ? total % clip.frames.length : Math.min(total, clip.frames.length - 1);
      const fired = [];
      for (let t = last + 1; t <= total; t++) {
        const f = clip.loop ? t % clip.frames.length : Math.min(t, clip.frames.length - 1);
        if (!clip.loop && t >= clip.frames.length) break;
        if (clip.events[f]) fired.push(clip.events[f] + "@" + f);
      }
      last = total;
      return { frame: clip.frames[frame], fired };
    },
  };
}

const sheet = { columns: 8, frameW: 48, frameH: 48, margin: 1, spacing: 2, pivotX: 20, pivotY: 46 };
console.log(frameRect(0, sheet));  // { x: 1, y: 1, w: 48, h: 48 }
console.log(frameRect(11, sheet)); // { x: 151, y: 51, w: 48, h: 48 }

console.log(drawCorner({ x: 300, y: 200 }, sheet, false)); // { x: 280, y: 154 }
console.log(drawCorner({ x: 300, y: 200 }, sheet, true));  // { x: 272, y: 154 }

// Бег: 8 кадров, 12 к/с, шаги на кадрах 2 и 6. Экран 60 Гц и 144 Гц
const run = { frames: [8, 9, 10, 11, 12, 13, 14, 15], fps: 12, loop: true, events: { 2: "step", 6: "step" } };
for (const hz of [60, 144]) {
  const p = createPlayer(run);
  let steps = 0;
  let frame = 0;
  for (let i = 0; i < hz; i++) {
    const r = p.update(1 / hz);
    steps += r.fired.length;
    frame = r.frame;
  }
  console.log(hz + " Гц: за 1 с шагов", steps, "кадр атласа", frame);
}
// 60 Гц: за 1 с шагов 3 кадр атласа 12
// 144 Гц: за 1 с шагов 3 кадр атласа 12

// Просадка: один кадр длиной 0,25 с не съедает событие шага
const p2 = createPlayer(run);
p2.update(0.01);
console.log(p2.update(0.25).fired); // [ 'step@2' ]

За 1 секунду на обоих экранах прозвучали 3 шага и показан один и тот же кадр атласа. После обновления длиной 0,25 с событие шага на кадре 2 не потерялось, хотя проигрыватель сразу перескочил на кадр 3.

Практический сценарий: готовим лист бега к игре

Подготовка листа занимает около 15 минут и избавляет от дрожания и сдвигов на этапе игры:

  1. Единый размер кадра. Экспортируйте все 8 кадров бега в одинаковой ячейке, например 48×48 пикселей, даже если фигура занимает меньше.
  2. Поле и зазор. Задайте поле 1 пиксель по краю листа и зазор 2 пикселя между кадрами, запишите эти числа рядом с листом.
  3. Опорная точка. Отметьте pivot у стоп на одном кадре и проверьте, что на всех остальных стопы стоят на той же строке пикселей.
  4. Частота. Начните с 12 к/с, затем сравните бег со скоростью перемещения героя: если ноги «скользят», поднимайте частоту или снижайте скорость.
  5. События. Поставьте метку «шаг» на кадры касания ногой земли, обычно на 2 и 6 из 8.
  6. Проверка. Запустите игру с принудительно заниженной частотой обновления и убедитесь, что звуков шагов за секунду столько же, сколько при нормальной.

Когда вам это не подойдёт

Покадровая анимация на атласе — не единственный способ оживить 2D-персонажа:

  • Скелетная анимация: если персонаж собран из частей с костями и плавными переходами между движениями, кадры в атласе заменяются ключами поворота костей, а формулы этой статьи нужны только для отдельных эффектов.
  • Сотни уникальных кадров на персонажа: когда клипов десятки, а кадров сотни, один лист перестаёт помещаться в разумный размер текстуры; придётся делить его по клипам и подгружать частями.
  • Чисто процедурная графика: если объекты рисуются фигурами и шейдерами, атлас и опорные точки кадров не нужны.

Мы разрабатываем J2DRU — браузерный 2D игровой движок; он в разработке и публично пока не открыт, заявку можно оставить на странице раннего доступа (подача заявки не гарантирует приглашение).

Источники (проверено: ноябрь 2026): MDN — CanvasRenderingContext2D.drawImage().

Читайте также:

Частые вопросы

Зачем собирать кадры в один спрайтовый лист?

Одно изображение загружается одним запросом, а все кадры рисуются из одной текстуры с разными прямоугольниками источника. Это проще в загрузке и обычно дешевле для отрисовки, чем десятки отдельных файлов.

Куда ставить опорную точку персонажа?

Для персонажа, который ходит по земле, — посередине между стопами на нижней линии кадра. Тогда смена кадров и разворот не сдвигают его относительно пола и хитбокса.

Какую частоту выбрать для покадровой анимации?

Рабочий диапазон для 2D-клипов — от 8 до 24 к/с. 8–10 к/с подходит для простого пиксельного бега и бездействия, около 12 к/с — для большинства циклов ходьбы и бега, 15–24 к/с — для быстрых ударов и эффектов. Частота клипа задаётся отдельно от частоты экрана.

Почему звук шага иногда пропадает?

Если проверять только текущий кадр, то при просадке FPS кадр со звуком можно перепрыгнуть. Нужно перебирать все кадры клипа, пройденные с прошлого обновления, и запускать события каждого из них.