От таймера к циклу анимации
В программах прошлых занятий шаг анимации вызывала команда setInterval через заданное число миллисекунд, независимо от того, когда монитор выводит очередной кадр. Шаги и кадры не совпадают, поэтому движение бывает неровным. На этом занятии анимация будет согласована с обновлением экрана: один шаг на каждый кадр. Чтобы скорость при этом не зависела от частоты монитора, смещение будет вычисляться по времени, прошедшему с предыдущего кадра. Кроме того, движение станет естественнее: появятся ускорение, затухание и шлейф.
Основные результаты
- объяснить, что такое кадр и частота обновления экрана;
- заменить
setIntervalциклом наrequestAnimationFrame; - разделить шаг анимации на функции
update()иdraw(); - задавать скорость в пикселях в секунду, чтобы анимация не зависела от частоты экрана;
- приостанавливать и продолжать анимацию;
- добавить ускорение, затухание скорости и шлейф;
- управлять объектом с клавиатуры.
Щелчок по рисунку подбрасывает шарики. Под рисунком показана частота обновления вашего экрана, измеренная этой страницей.
Конструкции, которые понадобятся на занятии
Справочник не изучается отдельно. Возвращайтесь к нему, когда в тексте или в заготовке встречается незнакомая запись.
| Запись | Назначение |
|---|---|
requestAnimationFrame(frame) | Просит браузер один раз вызвать функцию frame перед выводом следующего кадра экрана. Передаётся имя функции, без скобок. |
function frame(timestamp) { … } | Функция, которую вызывает requestAnimationFrame, получает время в миллисекундах, отсчитанное от загрузки страницы. |
performance.now() | Возвращает текущее время в миллисекундах по той же шкале, что и timestamp. |
Math.min(a, b) | Возвращает наименьшее из чисел. Math.min(dt, 0.1) не даёт значению превысить 0,1. |
setTimeout(f, 1000) | Вызывает функцию f один раз через 1000 мс. |
!paused | Логическое «не»: true превращается в false, false — в true. |
paused = !paused; | Переключает логическую переменную на противоположное значение. |
| Запись | Назначение |
|---|---|
v *= k; | Краткая запись v = v * k; (как += для сложения). |
a % b | Остаток от деления: 7 % 5 равно 2, 5 % 5 равно 0. |
"rgba(248, 250, 249, 0.25)" | Цвет с прозрачностью. Последнее число — непрозрачность от 0 (полностью прозрачный) до 1. |
document.addEventListener("keydown", f) | Вызывает f при нажатии любой клавиши; событие "keyup" возникает при отпускании. |
event.key | Название клавиши: "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", " " (пробел), "a". |
keys[event.key] = true; | Свойство объекта, имя которого хранится в переменной. keys.ArrowLeft и keys["ArrowLeft"] — одно и то же свойство. |
event.preventDefault() | Отменяет действие браузера по умолчанию, например прокрутку страницы стрелками. |
Изученные ранее функции, которые используются в заготовках
function distance(a, b) {
const dx = a.x - b.x;
const dy = a.y - b.y;
return Math.sqrt(dx * dx + dy * dy);
}
function side(a, b, p) {
return (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x);
}
function segmentsCross(p1, p2, a, b) {
const d1 = side(a, b, p1);
const d2 = side(a, b, p2);
const d3 = side(p1, p2, a);
const d4 = side(p1, p2, b);
return d1 * d2 < 0 && d3 * d4 < 0;
}Повторение занятия 5
выбор метода · пересечение отрезковФрагменты перетаскиваются мышью в пропуски. Можно также выбрать фрагмент щелчком, а затем щёлкнуть по пропуску. Повторный щелчок по заполненному пропуску возвращает фрагмент на место.
side(A, B, P) для A(0, 0), B(0, 10) и P(4, 5).d1 = 12, d2 = -30, d3 = 8, d4 = 5. Что можно утверждать?crossedSide.function crossedSide(p, tri) {
const next = { x: p.x + , y: p.y + };
const sides = triangleSides(tri);
for (let s = 0; s < sides.length; s++) {
if (segmentsCross(p, next, sides[s][0], sides[s][1])) {
return ;
}
}
return ;
}
- Проверить, пересекает ли траектория шага каждую сторону треугольника.
- Вычислить расстояние от частицы до центра охватывающей окружности.
- При пересечении отразить частицу от найденной стороны.
- Вычислить следующую позицию центра частицы.
- Если частица далеко от окружности, перейти к следующей частице.
Завершите работу прошлого занятия
Это время отведено на задания занятия 5, которые остались невыполненными. Если всё уже сделано, переходите к факультативному заданию на следующем экране.
Как продолжить работу
- Откройте конспект занятия 5 кнопкой слева. Он откроется в новой вкладке.
- Если работа велась на этом компьютере и в этом браузере, выберите «Продолжить»: код практикумов и отметки о выполненных заданиях сохранились.
- Если работа велась на другом компьютере, введите те же фамилию и имя. Задания придётся выполнить заново, но результаты попадут к учителю под вашим именем.
- Выполните недостающие практикумы, затем перенесите метод в проект.
- Нажмите «Завершить и отправить» на последнем экране занятия 5 и вернитесь на эту вкладку.
Метод траектории для многоугольника
Функция triangleSides из занятия 5 перечисляла три стороны вручную. Для многоугольника с любым числом вершин так поступить нельзя: заранее неизвестно, сколько сторон нужно записать. Поэтому вершины хранятся в массиве, а стороны строятся в цикле.
Многоугольник в программе
const polygon = [
{ x: 300, y: 40 },
{ x: 440, y: 120 },
{ x: 400, y: 270 },
{ x: 220, y: 280 },
{ x: 170, y: 130 }
];
Сторона с номером i соединяет вершины polygon[i] и polygon[i + 1]. Последняя сторона — исключение: она замыкает многоугольник и соединяет последнюю вершину с первой.
Замыкание многоугольника
Номер следующей вершины удобно вычислять как остаток от деления: (i + 1) % n, где n — число вершин.
| i | (i + 1) % 5 | Сторона |
|---|---|---|
| 0 | 1 | вершины 0 и 1 |
| 3 | 4 | вершины 3 и 4 |
| 4 | 0 | вершины 4 и 0 |
Подсказка: функция polygonSides
Создайте пустой массив sides. В цикле по i от 0 до poly.length - 1 добавляйте в него пару [poly[i], poly[(i + 1) % poly.length]]. После цикла верните массив.
Дополнительные задачи
- Добавьте в массив
polygonещё две вершины. Убедитесь, что остальной код изменять не пришлось. - Постройте невыпуклый многоугольник, например в форме буквы Г. Работает ли метод траектории для такой фигуры? Объясните почему.
Кадр и частота обновления экрана
Изображение на мониторе не меняется непрерывно. Монитор несколько десятков раз в секунду заново выводит изображение целиком. Каждое такое изображение называется кадром. Число кадров в секунду называется частотой обновления экрана и измеряется в герцах (Гц).
Частота и интервал между кадрами
| Частота экрана | Интервал между кадрами |
|---|---|
| 60 Гц | 1000 / 60 ≈ 16,7 мс |
| 75 Гц | 1000 / 75 ≈ 13,3 мс |
| 120 Гц | 1000 / 120 ≈ 8,3 мс |
| 144 Гц | 1000 / 144 ≈ 6,9 мс |
Большинство школьных мониторов работает с частотой 60 Гц, многие современные мониторы и телефоны — с частотой 90–144 Гц.
Как возникает движение
Если в каждом следующем кадре объект немного смещён, глаз воспринимает последовательность кадров как непрерывное движение.
Движение выглядит плавным при двух условиях: новое положение объекта появляется в каждом кадре, и смещения между соседними кадрами одинаковы.
setInterval не согласован с экраном: одно и то же положение объекта показывается то в двух кадрах подряд (жёлтые прямоугольники), то в одном. Поэтому движение получается неравномерным. При requestAnimationFrame каждому кадру соответствует новый шаг.Наблюдение
Верхний шарик перемещается функцией, которую вызывает setInterval(step, 30). Нижний шарик перемещается в цикле requestAnimationFrame. Средняя скорость обоих одинакова.
Следите за каждым шариком по очереди. Какой из них движется ровнее?
Цикл кадров на requestAnimationFrame
Функция requestAnimationFrame(f) просит браузер вызвать функцию f один раз — непосредственно перед выводом следующего кадра. Чтобы анимация продолжалась, функция f в конце сама запрашивает следующий кадр. Так получается цикл кадров: на каждый кадр экрана приходится ровно один шаг анимации.
function update() {
particles.forEach(moveParticle);
}
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
particles.forEach(drawParticle);
}
function frame() {
update();
draw();
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
Как работает цикл
- Последняя строка программы запрашивает первый кадр.
- Перед выводом кадра браузер вызывает функцию
frame. - Функция
frameизменяет модель, рисует её и запрашивает следующий кадр. - Шаги 2 и 3 повторяются, пока открыта страница.
В requestAnimationFrame передаётся имя функции frame без скобок. Браузер сам вызовет её в нужный момент.
Функция update()
Изменяет модель: координаты и скорости объектов, проверяет столкновения и выполняет реакцию на них. Ничего не рисует.
Функция draw()
Очищает холст и рисует текущее состояние модели. Ничего в модели не изменяет.
Разделение шага на две функции даёт три преимущества. Каждая функция решает одну задачу, поэтому программу легче читать и искать в ней ошибки. Во время паузы можно рисовать модель, не изменяя её. Наконец, во второй части занятия время кадра понадобится только функции update.
setInterval(tick, 30) | requestAnimationFrame(frame) | |
|---|---|---|
| Когда вызывается функция | Через заданный интервал, независимо от экрана | Перед выводом очередного кадра |
| Сколько раз | Повторяет вызовы сама | Один раз; следующий кадр нужно запросить снова |
| Плавность | Шаги и кадры не совпадают, возможны рывки | Один шаг на каждый кадр |
| В скрытой вкладке | Продолжает работать, но реже | Приостанавливается |
| Скорость, заданная в пикселях за шаг | Одинакова на любом экране | Зависит от частоты экрана |
requestAnimationFrame делает движение плавным и не тратит ресурсы в скрытой вкладке. Но скорость, заданная в пикселях за шаг, теперь зависит от экрана: на мониторе 120 Гц объекты движутся вдвое быстрее, чем на мониторе 60 Гц. Как вернуть независимость скорости, показано во второй части занятия.requestAnimationFrame(frame()) со скобками вызывает функцию немедленно, а она снова вызывает себя. Возникает бесконечная цепочка вызовов, и браузер сообщает об ошибке Maximum call stack size exceeded (переполнение стека вызовов). Если в конце frame пропущен запрос следующего кадра, будет нарисован только один кадр. Если цикл запущен дважды, каждый кадр модель изменяется два раза, и объекты движутся вдвое быстрее.Тренировка: цикл кадров
p.x += p.vx;ctx.clearRect(0, 0, canvas.width, canvas.height);if (p.y > canvas.height - p.r) p.vy = -p.vy;ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);requestAnimationFrame(frame);update();requestAnimationFrame(frame);function frame() {}draw();
frame вызывалась перед каждым кадром.function frame() {
update();
draw();
requestAnimationFrame();
}
frame пропущена строка requestAnimationFrame(frame);. Что произойдёт после запуска программы?Практикум: переход на requestAnimationFrame
Заготовка устроена так же, как программы прошлых занятий: шаг анимации выполняет функция tick, которую вызывает setInterval. В этой функции изменение положения и рисование перемешаны. Переделайте программу в цикл кадров с отдельными функциями update и draw.
Дополнительная задача: частота вашего экрана
Объявите переменную let frameCount = 0; и увеличивайте её на 1 в функции update. В конец программы добавьте строку:
setTimeout(() => console.log("Кадров за секунду:", frameCount), 1000);
Через секунду после запуска в консоли появится число кадров, выведенных за первую секунду, то есть частота обновления вашего экрана. Сравните результат с соседями: у кого-то он может отличаться.
Итоги первого урока
Изучено
Анимация состоит из кадров. Цикл на requestAnimationFrame выполняет ровно один шаг на каждый кадр экрана, а шаг разделён на изменение модели (update) и рисование (draw).
После перерыва
После перехода на requestAnimationFrame скорость в пикселях за кадр стала зависеть от частоты экрана. Программа научится учитывать время кадра, останавливать и продолжать анимацию, а движение станет естественнее.
Время кадра и скорость в пикселях в секунду
В программах прошлых занятий скорость задана в пикселях за шаг: за каждый шаг шарик смещается на vx пикселей. Пока шаги вызывал setInterval, их число в секунду не зависело от монитора: при интервале 30 мс это примерно 33 шага. В цикле на requestAnimationFrame шаг выполняется в каждом кадре, а число кадров в секунду зависит от экрана: 60 на экране 60 Гц, 120 на экране 120 Гц. Значит, на втором экране тот же шарик движется вдвое быстрее. Чтобы скорость не зависела от экрана, её задают в пикселях в секунду, а смещение за кадр вычисляют по времени, прошедшему с предыдущего кадра.
Исследование
Модель показывает, как программа работала бы на экране с выбранной частотой. Верхний шарик смещается на 3 пикселя за кадр, нижний движется со скоростью 180 пикселей в секунду. Переключите частоту и сравните скорости шариков.
let lastTime = performance.now();
function frame(timestamp) {
let dt = (timestamp - lastTime) / 1000;
lastTime = timestamp;
dt = Math.min(dt, 0.1);
update(dt);
draw();
requestAnimationFrame(frame);
}
function update(dt) {
particles.forEach(p => {
p.x += p.vx * dt; // vx — пикселей в секунду
p.y += p.vy * dt;
});
}
Параметр timestamp и интервал dt
Браузер передаёт в функцию frame один аргумент — время в миллисекундах, отсчитанное от загрузки страницы. Разность между временем текущего и предыдущего кадров называется интервалом dt (от англ. delta time). Деление на 1000 переводит миллисекунды в секунды.
Начальное значение. Функция performance.now() возвращает текущее время по той же шкале. Поэтому уже первый интервал вычисляется правильно.
Ограничение dt. В скрытой вкладке браузер не вызывает frame. После возвращения интервал может составить несколько секунд, и объекты перескочат через половину холста. Math.min(dt, 0.1) ограничивает интервал десятой долей секунды.
Пауза и продолжение
let paused = false;
canvas.addEventListener("click", () => {
paused = !paused;
});
function frame(timestamp) {
let dt = (timestamp - lastTime) / 1000;
lastTime = timestamp;
dt = Math.min(dt, 0.1);
if (!paused) {
update(dt);
}
draw();
requestAnimationFrame(frame);
}
Как работает пауза
Переменная paused хранит состояние анимации. Щелчок по холсту переключает её на противоположное значение.
Во время паузы цикл кадров не останавливается: пропускается только изменение модели, а рисование продолжается. Время lastTime обновляется в каждом кадре. Поэтому после продолжения интервал dt снова мал, и объекты не перескакивают.
setInterval(tick, 30) шагов в секунду 1000 / 30 ≈ 33.Три способа запуска анимации
| Способ | Плавность | Скорость на экранах 60 и 120 Гц | Скрытая вкладка | Что нужно учитывать |
|---|---|---|---|---|
setInterval, скорость в пикселях за шаг | Возможны рывки: шаги не совпадают с кадрами | Одинаковая | Таймер продолжает срабатывать | Если браузер занят, шаги задерживаются и анимация замедляется |
requestAnimationFrame, скорость в пикселях за кадр | Плавно: один шаг на кадр | На 120 Гц вдвое быстрее | Цикл приостанавливается | Если кадры пропускаются, анимация замедляется |
requestAnimationFrame, скорость в пикселях в секунду и dt | Плавно: один шаг на кадр | Одинаковая | Цикл приостанавливается | Скорость умножается на dt; dt ограничивается, иначе после паузы объекты перескакивают |
setInterval. Даже если отдельные кадры задерживаются, объект за секунду проходит одно и то же расстояние. Этот способ используется в практикумах и в собственном проекте.Тренировка: время кадра
2000, текущий — в момент 2016.7 (миллисекунды). Чему равно dt?vx = 240 пикселей в секунду, интервал dt = 0.02 с. На сколько пикселей сместится шарик за этот кадр?function frame(timestamp) {
let dt = (timestamp - ) / ;
lastTime = ;
update(dt);
draw();
requestAnimationFrame(frame);
}
function moveParticle(p, dt) {
p.x += p.vx * ;
}
dt. С какой скоростью он движется на экранах 60 Гц и 120 Гц?frame продолжает вызываться. Какое действие в ней пропускается?Практикум: время кадра и пауза
Заготовка — результат практикума первой части. Скорости частиц пока заданы в пикселях за кадр. Переведите программу на скорости в пикселях в секунду и добавьте паузу по щелчку.
Ускорение, затухание и шлейф
До сих пор скорость объектов менялась только при отражении. В реальном мире скорость изменяется постепенно: падающий предмет разгоняется, катящийся шар замедляется. Учёт времени кадра позволяет описать такие изменения так, что они не будут зависеть от частоты экрана.
Ускорение
Ускорение показывает, на сколько изменяется скорость за секунду. Под действием тяжести вертикальная скорость каждую секунду увеличивается на G пикселей в секунду.
При отскоке от пола часть скорости теряется: она умножается на число BOUNCE, меньшее 1.
Затухание
Скорость постепенно уменьшается, как у шайбы на льду или у шарика в воде. В каждом кадре скорость теряет долю FRICTION · dt.
При FRICTION = 1.5 и 60 кадрах в секунду скорость уменьшается на 1,5 / 60 = 2,5 % за кадр.
Шлейф
Вместо полной очистки холст заливают полупрозрачным цветом фона. Прежние изображения исчезают не сразу, а бледнеют в течение нескольких кадров.
Чем меньше непрозрачность заливки, тем длиннее шлейф.
function moveParticle(p, dt) {
p.vy += G * dt; // ускорение
p.vx *= 1 - FRICTION * dt; // затухание
p.vy *= 1 - FRICTION * dt;
p.x += p.vx * dt;
p.y += p.vy * dt;
if (p.y > canvas.height - p.r) {
p.y = canvas.height - p.r;
p.vy = -Math.abs(p.vy) * BOUNCE; // отскок
}
}
function draw() {
// шлейф: полупрозрачная заливка
// вместо ctx.clearRect
ctx.fillStyle = "rgba(248, 250, 249, 0.25)";
ctx.fillRect(0, 0, canvas.width, canvas.height);
particles.forEach(drawParticle);
}
Исследование
Включайте эффекты по одному и в сочетании. Почему при включённой тяжести шарики со временем перестают подпрыгивать? Что происходит при включённом затухании без тяжести?
Практикум: ускорение, затухание и шлейф
В заготовке уже есть цикл кадров с учётом dt и отскок от пола с потерей скорости. Щелчок по рисунку подбрасывает шарики. Добавьте три эффекта. После выполнения заданий измените значения констант G, FRICTION и BOUNCE и пронаблюдайте, как меняется движение.
Переведите проект на цикл кадров
Замените в своём проекте setInterval циклом на requestAnimationFrame с учётом dt и добавьте один эффект из третьей части: ускорение, затухание или шлейф.
Порядок действий
- Откройте папку проекта. Создайте копию
script.jsс понятным именем, напримерscript-interval.js. - Откройте
script.jsв Notepad++, аindex.html— в браузере. - Найдите функцию шага анимации, которую вызывает
setInterval. Разделите её наupdate(dt)иdraw(). - Удалите вызов
setInterval. Вставьте переменнуюlastTimeи функциюframeиз практикума части 2 и запустите цикл строкойrequestAnimationFrame(frame);. - Переведите скорости в пиксели в секунду: умножьте их на число шагов в секунду старой программы. При
setInterval(tick, 30)это примерно 33. - Добавьте один эффект из третьей части.
- Сохраните файл сочетанием Ctrl + S и обновите страницу клавишей F5. Сохраните актуальную копию проекта в Google Drive.
Проверка
crossedSide вычисляла следующую позицию как p.x + p.vx. Теперь скорость измеряется в пикселях в секунду, а смещение за кадр равно p.vx * dt. Передайте dt в функцию и вычисляйте следующую позицию как p.x + p.vx * dt, иначе траектория окажется в десятки раз длиннее настоящей.update и draw. Перед вставкой предложенного кода убедитесь, что можете объяснить каждую его строку.Управление объектом с клавиатуры
При нажатии клавиши браузер создаёт событие keydown, при отпускании — событие keyup. Обработчики этих событий регистрируются для всего документа: document.addEventListener. Какая клавиша нажата, показывает свойство event.key.
const mover = { x: 300, y: 160, r: 16, speed: 240 };
const keys = {};
document.addEventListener("keydown", event => {
keys[event.key] = true;
});
document.addEventListener("keyup", event => {
keys[event.key] = false;
});
function update(dt) {
if (keys.ArrowLeft) {
mover.x -= mover.speed * dt;
}
if (keys.ArrowRight) {
mover.x += mover.speed * dt;
}
}
Почему объект не перемещают в обработчике
Если удерживать клавишу, браузер повторяет событие keydown. Но сначала он делает паузу около полусекунды, а затем повторяет событие с частотой, которая не связана с кадрами экрана.
Объект, перемещаемый прямо в обработчике, сдвигается рывком, замирает и только потом движется, причём неравномерно. Поэтому обработчики лишь запоминают в объекте keys, какие клавиши нажаты, а перемещение выполняет функция update в каждом кадре.
Исследование
Щёлкните по рисунку и удерживайте клавишу → или ←. Верхний объект перемещается в обработчике keydown, нижний — в функции update по объекту keys.
keys[event.key] = true создаёт или изменяет свойство, имя которого хранится в event.key. Если клавиша ещё ни разу не нажималась, свойства нет, его значение равно undefined, и в условии if оно действует как false.keydown вызывают event.preventDefault().Практикум: движение управляемого объекта стрелками
Щёлкните по рисунку, чтобы нажатия клавиш попадали в программу. Проверка заданий имитирует нажатия сама, поэтому для неё щелчок не нужен.
Дополнительные задачи
- Диагональ. При одновременном нажатии двух стрелок объект движется по диагонали быстрее, чем по прямой: в √2 ≈ 1,41 раза. Исправьте это: если нажаты две перпендикулярные стрелки, умножайте оба смещения на 0,71.
- Плавный разгон. Храните скорость объекта в свойствах
vxиvy. Нажатая стрелка задаёт ускорение, а затухание из третьей части постепенно останавливает объект после отпускания клавиши.
Проверьте результат занятия
Проект
Объяснение
requestAnimationFrame с учётом dt в собственном проекте. Если перенос в проект не завершён, минимальным результатом считаются выполненные практикумы.Завершение работы
Нажмите кнопку после проверки всех чек-листов. Результаты тренажёров и практикумов будут отправлены учителю.