rotate() и scale()

В прошлый раз мы сдвигали мир через translate(); теперь научимся его крутить и растягивать — и наконец заставим CodeChick кружиться и расти прямо на холсте.
Главная мысль урока: rotate() поворачивает не фигуру, а всю систему координат вокруг точки (0, 0), а scale() растягивает эту же сетку. Поэтому почти всегда сначала идёт translate(), потом rotate()/scale(), и только затем сама фигура.

Зачем вообще крутить мир

Представь, что ты листаешь ленту с короткими видео. Кто-то снял, как фигурка персонажа медленно вращается на месте, будто на витрине, а потом резко увеличивается, заполняя весь экран. Выглядит как магия, но за этим стоит ровно две функции: rotate() и scale(). И ты сегодня научишься делать то же самое — только с нашим цыплёнком CodeChick.

В прошлом уроке про translate() мы поняли важную вещь: когда ты вызываешь translate(x, y), ты не двигаешь фигуру — ты двигаешь всю сетку, всю систему координат. Начало координат (точка (0, 0)) уезжает в новое место, и дальше всё рисуется относительно него. С rotate() и scale() та же логика: ты не крутишь и не растягиваешь фигуру, ты крутишь и растягиваешь сам лист, на котором рисуешь.

Почему это вообще так устроено и зачем нам крутить именно лист, а не фигуру? Потому что один раз повернув или растянув сетку, ты можешь нарисовать поверх неё сколько угодно фигур — и все они сразу окажутся повёрнутыми. Это куда удобнее, чем считать новые координаты каждого уголка треугольника вручную через формулы с синусами и косинусами. Ты говоришь холсту «теперь рисуй наклонно», а дальше просто рисуешь как обычно. Этим приёмом пользуются буквально все: от анимации логотипов в заставках до вращающихся монеток в играх, которые ты собираешь по уровню. За красивым кадром почти всегда прячется пара строк трансформаций — и сегодня ты научишься их писать.

Вот к чему мы придём к концу урока — крутящийся и пульсирующий CodeChick:

let angle = 0;

function setup() {
  createCanvas(400, 400);
  angleMode(DEGREES);
}

function draw() {
  background(135, 206, 235);
  translate(width / 2, height / 2);
  rotate(angle);
  let s = 1 + 0.3 * sin(angle * 4);
  scale(s);
  drawChick();
  angle = angle + 1;
}

function drawChick() {
  noStroke();
  fill(255, 214, 10);
  ellipse(0, 0, 80, 80);
  fill(255, 153, 0);
  triangle(35, -5, 35, 5, 55, 0);
  fill(0);
  ellipse(15, -15, 8, 8);
}

Результат: в центре голубого неба жёлтый кружок-цыплёнок с оранжевым клювом и чёрным глазом плавно вращается по кругу и одновременно слегка пульсирует — то немного увеличивается, то уменьшается, как будто дышит. Не пугайся незнакомых строк: к концу урока ты разберёшь тут каждую.

rotate(): крутим не фигуру, а лист

Метафора простая. Возьми лист из скетчбука и нарисуй на нём цыплёнка в левом верхнем углу. А теперь поверни сам лист на сорок пять градусов — цыплёнок повернётся вместе с ним, потому что он нарисован на листе. Функция rotate() делает ровно это: поворачивает систему координат вокруг её начала — точки (0, 0).

Градусы и радианы

По умолчанию p5.js считает углы не в градусах, а в радианах. Это привычная математикам единица, но для нас, кто хочет «повернуть на 90», она неудобна. Поэтому в самом начале, в setup(), мы один раз говорим:

function setup() {
  createCanvas(400, 400);
  angleMode(DEGREES);
}

Результат: создаётся холст 400 на 400 пикселей, и теперь все углы в rotate() мы задаём в привычных градусах: 90 — четверть оборота, 180 — половина, 360 — полный круг.

Без этой строчки rotate(90) повернул бы мир на 90 радиан — это больше четырнадцати полных оборотов, и результат был бы непредсказуемым. Так что договорились: пишем angleMode(DEGREES) и думаем в градусах.

Маленькая шпаргалка, чтобы углы не путались в голове. Полный круг — это 360 градусов. Половина оборота, когда цыплёнок смотрит ровно в противоположную сторону, — 180. Четверть, поворот «вбок», — 90. А совсем лёгкий наклон, который заметен, но не ломает картинку, — 10–15 градусов. Если хочешь, чтобы что-то медленно вращалось без остановки, ты просто прибавляешь к углу по чуть-чуть каждый кадр — например по 1 градусу, — и за 360 кадров (примерно шесть секунд при стандартной частоте) получится один полный оборот. Чем больше прибавляешь, тем быстрее крутится.

Первый поворот

Посмотрим, что произойдёт, если просто повернуть и нарисовать квадрат:

function setup() {
  createCanvas(400, 400);
  angleMode(DEGREES);
}

function draw() {
  background(240);
  rotate(30);
  fill(255, 214, 10);
  rect(50, 50, 100, 100);
}

Результат: жёлтый квадрат нарисован не ровно, а наклонён на 30 градусов. Но наклонился он не вокруг своего центра, а уехал куда-то вбок и вверх — потому что поворот произошёл вокруг угла холста, точки (0, 0), а квадрат стоит далеко от неё. Это и есть та самая ловушка, ради которой нам нужен translate().

Почему translate() идёт перед rotate()

Вернёмся к листу из скетчбука. Когда ты крутишь лист, он вращается вокруг той точки, где ты его держишь пальцем. В p5.js этот «палец» по умолчанию стоит в левом верхнем углу — в точке (0, 0). Поэтому любой rotate() крутит мир вокруг угла холста.

А мы почти всегда хотим крутить вокруг центра объекта. Решение: сначала translate() переносит начало координат туда, где должна быть «булавка» поворота, и только потом rotate() крутит вокруг неё.

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

Запомни этот порядок как рецепт из трёх шагов:

  1. translate(x, y) — переставь булавку (начало координат) в нужную точку.
  2. rotate(угол) — поверни мир вокруг этой булавки.
  3. Рисуй фигуру вокруг (0, 0) — то есть рисуй её относительно новой булавки, а не относительно угла холста.

Сравни два кода. Сначала так, как делать не надо:

function draw() {
  background(240);
  rotate(30);
  fill(255, 214, 10);
  ellipse(200, 200, 100, 100);
}

Результат: жёлтый круг хотели поставить в центр, но из-за поворота вокруг угла холста он уехал в сторону и наполовину вылез за край. Вышло криво.

А теперь правильно:

function draw() {
  background(240);
  translate(200, 200);
  rotate(30);
  fill(255, 214, 10);
  ellipse(0, 0, 100, 100);
}

Результат: жёлтый круг ровно в центре холста. Поскольку круг симметричный, поворот на нём не заметен, зато он точно там, где мы хотели. Обрати внимание: после translate(200, 200) мы рисуем круг в точке (0, 0) — потому что булавка теперь в центре, и (0, 0) означает «центр холста».

Делаем поворот видимым

Чтобы увидеть сам поворот, нарисуем не симметричный круг, а клюв цыплёнка — острый треугольник, который торчит вбок:

let angle = 0;

function setup() {
  createCanvas(400, 400);
  angleMode(DEGREES);
}

function draw() {
  background(135, 206, 235);
  translate(width / 2, height / 2);
  rotate(angle);
  fill(255, 214, 10);
  noStroke();
  ellipse(0, 0, 90, 90);
  fill(255, 153, 0);
  triangle(40, -8, 40, 8, 65, 0);
  angle = angle + 1;
}

Результат: цыплёнок-кружок стоит в центре, а его оранжевый клюв медленно обходит по кругу — будто голова вращается, осматриваясь по сторонам. С каждым кадром angle увеличивается на 1 градус, поэтому за 360 кадров клюв делает полный оборот. Мы используем width / 2 и height / 2 вместо чисел 200 — так центр посчитается сам, даже если ты поменяешь размер холста.

scale(): растягиваем сетку

Теперь второй герой урока — scale(). Если rotate() крутит лист, то scale() его растягивает или сжимает, как будто ты тянешь картинку за уголок на телефоне, увеличивая её двумя пальцами.

Аргумент scale() — это множитель:

  • scale(1) — обычный размер, ничего не меняется.
  • scale(2) — всё вдвое крупнее.
  • scale(0.5) — всё вдвое мельче.
  • scale(1, 2) — по ширине как обычно, по высоте вдвое выше (можно растягивать по осям отдельно).

И снова важная тонкость: scale() растягивает сетку от точки (0, 0). Если объект далеко от начала координат, при увеличении он не только вырастет, но и уедет в сторону. Представь, что ты растягиваешь резиновую плёнку, прибитую гвоздиком в углу: точка прямо у гвоздика остаётся на месте, а всё, что дальше, разъезжается тем сильнее, чем дальше оно было. Поэтому объект, нарисованный в углу, при увеличении буквально «убегает» от начала координат. Лекарство то же самое, что и с поворотом — сначала translate() в центр, потом scale(), и тогда объект растёт ровно на месте.

function setup() {
  createCanvas(400, 400);
}

function draw() {
  background(135, 206, 235);
  translate(width / 2, height / 2);
  scale(1.8);
  fill(255, 214, 10);
  noStroke();
  ellipse(0, 0, 80, 80);
  fill(0);
  ellipse(15, -15, 8, 8);
}

Результат: цыплёнок в центре, но почти вдвое крупнее обычного — большой жёлтый круг с чёрной точкой-глазом. Важно: мы не меняли число 80 в ellipse, мы растянули всю сетку через scale(1.8), и круг увеличился сам.

Пульсация через scale() и sin()

Чтобы цыплёнок «дышал», нам нужно, чтобы множитель не стоял на месте, а плавно ходил туда-сюда. Для этого идеально подходит синус — функция, которая мягко колеблется между −1 и 1. Не пугайся математики: тебе достаточно знать, что sin(чего-то растущего) даёт волну.

let t = 0;

function setup() {
  createCanvas(400, 400);
  angleMode(DEGREES);
}

function draw() {
  background(135, 206, 235);
  translate(width / 2, height / 2);
  let s = 1 + 0.3 * sin(t);
  scale(s);
  fill(255, 214, 10);
  noStroke();
  ellipse(0, 0, 80, 80);
  t = t + 4;
}

Результат: цыплёнок в центре плавно пульсирует — то чуть больше, то чуть меньше, размер гуляет примерно от 0.7 до 1.3 от обычного. sin(t) даёт волну от −1 до 1, мы умножаем её на 0.3 и прибавляем 1, поэтому множитель колеблется вокруг единицы. Поменяй 0.3 на 0.6 — и цыплёнок начнёт пульсировать сильнее.

Совмещаем поворот и масштаб

Самое вкусное — когда оба приёма работают вместе. Порядок такой же, как в нашем первом примере: translate(), потом rotate(), потом scale(), и только в конце рисуем фигуру вокруг (0, 0). Именно так устроен код из самого начала урока. Разберём его построчно:

  1. translate(width / 2, height / 2) — переносим булавку в центр холста.
  2. rotate(angle) — крутим мир вокруг центра; angle растёт каждый кадр.
  3. let s = 1 + 0.3 * sin(angle * 4) — считаем пульсирующий множитель.
  4. scale(s) — растягиваем сетку этим множителем.
  5. drawChick() — рисуем цыплёнка вокруг (0, 0), и он наследует и поворот, и масштаб.

Заметь: трансформации складываются по очереди. Сначала сдвинули, потом на уже сдвинутом мире повернули, потом на уже повёрнутом — растянули. Поменяешь порядок — получишь другой результат. Это как одеваться: сначала носки, потом ботинки, наоборот не выйдет.

Чтобы прочувствовать это, попробуй мысленно поменять местами строки. Если сделать rotate() до translate(), то сначала повернётся пустая сетка вокруг угла холста, а потом по уже наклонённым осям произойдёт сдвиг — и твой центр окажется не там, где ты думал, а где-то по диагонали. А если поставить scale(2) перед translate(100, 0), то сам сдвиг тоже удвоится: мир уедет не на 100 пикселей, а на 200, потому что растянулись в том числе и расстояния. Это не баг, а честная математика: каждая следующая трансформация работает поверх уже изменённого мира. Запомни рабочий порядок «сдвиг — поворот — масштаб», и в девяти случаях из десяти всё встанет на свои места.

Частые ошибки и подводные камни

  • Забыл angleMode(DEGREES). Тогда rotate(90) поворачивает на 90 радиан, а не градусов, и фигура улетает в неожиданное место. Либо ставь angleMode(DEGREES) в setup(), либо честно пиши радианы через radians(90).
  • rotate() без translate(). Поворот идёт вокруг угла холста (0, 0), и объект уезжает за край. Почти всегда тебе нужно сначала перенести начало координат в центр объекта.
  • Рисуешь по старым координатам после translate(). После translate(200, 200) точка (0, 0) уже в центре. Если по привычке написать ellipse(200, 200, ...), фигура уедет в нижний правый угол. Рисуй вокруг (0, 0).
  • Не сбрасываешь трансформации между кадрами. Кажется, что translate и rotate накапливаются и всё разъезжается. На самом деле p5.js сам сбрасывает систему координат в начале каждого draw() — но если ты крутишь внутри цикла for много объектов, обязательно оборачивай каждый в push() и pop(), иначе повороты сложатся друг с другом. Подробно про push/pop будет в следующих уроках, но знай о ловушке заранее.
  • scale(0). Множитель ноль схлопывает всё в точку — холст будто пустой. А отрицательный scale(-1, 1) отзеркаливает картинку: иногда это полезно, но если получилось случайно, ты долго будешь думать, почему клюв смотрит не туда.

Мини-практика: вращающаяся стая

Собери свой скетч. Задача — сделать «карусель» из нескольких цыплят, которые кружатся вокруг центра, и при этом каждый ещё и пульсирует. Вот каркас, который тебе нужно доделать:

let angle = 0;

function setup() {
  createCanvas(400, 400);
  angleMode(DEGREES);
}

function draw() {
  background(135, 206, 235);
  translate(width / 2, height / 2);
  rotate(angle);

  // TODO 1: нарисуй цыплёнка не в (0,0), а на расстоянии,
  // например в (120, 0) — тогда при rotate он поедет по кругу.

  // TODO 2: добавь scale() с пульсацией через sin(),
  // чтобы цыплёнок дышал во время вращения.

  fill(255, 214, 10);
  noStroke();
  ellipse(0, 0, 60, 60);

  angle = angle + 1.5;
}

Подсказки: чтобы цыплёнок ехал по орбите, рисуй его в точке вроде (120, 0) — поворот мира потащит эту точку по кругу. Хочешь несколько цыплят? Оберни рисование в цикл for и перед каждым делай дополнительный rotate(360 / количество), не забывая про push()/pop(). А пульсацию добавь строкой scale(1 + 0.2 * sin(angle * 5)) сразу после translate.

Поэкспериментируй: поменяй 1.5 на 5 — карусель закрутится быстрее. Поставь 120 побольше — орбита станет шире. Это твой скетч, ломай и собирай заново сколько хочешь.

Итоги и что дальше

Сегодня ты освоил две мощные трансформации:

  • rotate(угол) поворачивает всю систему координат вокруг точки (0, 0), а не саму фигуру.
  • scale(множитель) растягивает или сжимает сетку, тоже от точки (0, 0).
  • Чтобы крутить и масштабировать вокруг центра объекта, сначала идёт translate(), и только потом rotate() и scale().
  • Трансформации складываются по очереди, поэтому порядок важен.
  • Не забывай про angleMode(DEGREES) и рисуй фигуры вокруг нового начала координат.

Ты заставил CodeChick вращаться и пульсировать — но пока каждый объект крутится одинаково. А что, если нарисовать целую стаю, где у каждого цыплёнка свой поворот и размер, и при этом ничего не разъезжается? Для этого нужна пара функций-«закладок» — push() и pop(), которые сохраняют и восстанавливают состояние сетки. Именно ими мы займёмся в следующем уроке. До встречи на холсте!

Проверьте себя
1. Вокруг какой точки rotate() поворачивает систему координат по умолчанию?
AВокруг центра холста
BВокруг точки (0, 0) — левого верхнего угла
CВокруг центра нарисованной фигуры
DВокруг курсора мыши
2. Почему translate() обычно вызывают перед rotate()?
AЧтобы перенести начало координат в нужную точку, и поворот шёл уже вокруг неё
BПотому что rotate() не работает без translate()
CЧтобы ускорить отрисовку кадра
DЧтобы перевести углы в градусы
3. Что сделает scale(0.5)?
AПовернёт мир на пол-оборота
BСделает всё вдвое крупнее
CСделает всё вдвое мельче
DСдвинет сетку на 0.5 пикселя
4. Что произойдёт, если забыть angleMode(DEGREES) и написать rotate(90)?
AНичего, 90 и так считается в градусах по умолчанию
BМир повернётся ровно на четверть оборота
Cp5.js поймёт 90 как радианы, и поворот будет огромным и неожиданным
DСкетч выдаст ошибку и остановится
5. После translate(width/2, height/2) в какой точке надо рисовать фигуру, чтобы она оказалась в центре холста?
AВ точке (width/2, height/2)
BВ точке (0, 0)
CВ точке (200, 200)
DВ любой — translate этого не меняет
6. Зачем в пульсации используют выражение 1 + 0.3 * sin(t) как аргумент scale()?
AЧтобы множитель плавно колебался вокруг 1, давая эффект дыхания
BЧтобы фигура поворачивалась на 0.3 градуса
CЧтобы сбросить трансформации между кадрами
DЧтобы перевести радианы в градусы