push() и pop(): стек трансформаций

Сегодня ты научишься главному фокусу всех трансформаций: ставить «закладку» в системе координат через push() и возвращаться к ней через pop() — чтобы повороты одного цыплёнка не утаскивали за собой всю остальную картинку.

Главная идея урока: трансформации (translate, rotate, scale) не отменяются сами — они накапливаются и действуют на всё, что нарисовано после них. push() запоминает текущее состояние холста, а pop() возвращает к нему. Всё, что между ними, остаётся в своём «пузыре» и не портит соседей.

Зачем тебе push() и pop()

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

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

Решение — пара push() и pop(). Это как кнопки «сохранить» и «откатить» для всей системы координат. Перед тем как крутить одного цыплёнка, ты говоришь push() — «запомни, как сейчас стоит холст». Крутишь, рисуешь. А потом pop() — «верни всё как было». Следующий цыплёнок рисуется на чистой, не перекошенной сетке. К концу урока у тебя будет полянка из трёх независимо повёрнутых цыплят, каждый со своим наклоном, и ни один не утащит за собой остальных. Поехали разбираться.

Как работает стек состояний

Метафора: стопка кальки на чертеже

Вспомни, как чертят на бумаге. У тебя есть основной лист, а сверху ты кладёшь прозрачную кальку, чтобы что-то набросать, не пачкая оригинал. Покрутил кальку, понаклонял, нарисовал на ней деталь — а когда закончил, просто снял её, и под ней основной лист остался ровным и нетронутым.

push() — это «положить новый лист кальки поверх». Все твои translate, rotate, scale и даже цвет заливки теперь действуют на этой кальке. А pop() — это «снять кальку», вернуться к листу, который был под ней, со всеми его настройками. Калька со всеми перекосами выбрасывается, а основа цела.

Стек (по-английски stack — «стопка») — это способ хранить данные стопкой: последнее, что положил сверху, первым и снимаешь. push() кладёт текущее состояние холста на верх стопки, pop() снимает верхний слой. Поэтому пары всегда вложены аккуратно, как матрёшки, а не пересекаются крест-накрест.

Что именно запоминает push()? Не картинку — её стирать поздно, она уже нарисована. Он запоминает настройки: текущую систему координат (все накопленные сдвиги, повороты, масштабы) и стиль (fill, stroke, толщину линии и так далее). pop() возвращает ровно эти настройки. Поэтому если внутри пары ты сменил цвет заливки на красный, после pop() заливка снова станет такой, какой была до push() — менять её обратно вручную не нужно.

Почему трансформации накапливаются

Главное, что надо уложить в голове: трансформации в p5.js не разовые. Когда ты пишешь translate(100, 0), ты не «рисуешь со сдвигом один раз» — ты сдвигаешь саму систему координат, и она остаётся сдвинутой. Следующий translate(100, 0) сдвинет ещё на 100, итого на 200. Два rotate подряд сложатся в один большой угол. Это и называется «накопление».

В draw() есть тонкость: эта функция вызывается заново 60 раз в секунду, и в начале каждого кадра p5.js сбрасывает систему координат к исходной. Поэтому если у тебя один rotate(0.01) в draw() без цикла — фигура не закрутится бесконечно, она просто повёрнута на 0.01 в каждом кадре. А вот внутри одного кадра накопление работает в полную силу: всё, что ты натрансформировал, действует на каждую следующую фигуру до конца кадра — пока не вмешается pop().

Разбираем на примерах

Пример 1: что будет БЕЗ push()/pop()

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

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

function draw() {
  background(135, 206, 235);

  // первый цыплёнок слева — рисуем как обычно
  fill(255, 209, 64);
  circle(120, 150, 70);

  // хотим повернуть только второго, справа
  translate(280, 150);   // сдвинули центр координат к нему
  rotate(0.4);           // наклонили
  fill(255, 209, 64);
  circle(0, 0, 70);      // второй цыплёнок

  // а теперь рисуем землю — и она тоже едет!
  fill(110, 180, 90);
  rect(-200, 80, 600, 80);
}

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

Вот она, ловушка из начала урока. Трансформации накопились и потащили за собой всё, что нарисовано ниже. Чтобы наклон жил только внутри «своего» цыплёнка, его нужно завернуть в push() и pop().

Пример 2: тот же скетч, но с push() и pop()

Чиним. Оборачиваем трансформации второго цыплёнка в пару — ставим «закладку» до и возвращаемся после.

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

function draw() {
  background(135, 206, 235);

  // первый цыплёнок слева
  fill(255, 209, 64);
  circle(120, 150, 70);

  push();                // запомнили чистую систему координат
  translate(280, 150);   // эти трансформации живут только внутри пузыря
  rotate(0.4);
  fill(255, 209, 64);
  circle(0, 0, 70);      // второй цыплёнок — наклонён
  pop();                 // вернули всё как было

  // земля рисуется на ровной сетке — больше не едет
  fill(110, 180, 90);
  rect(0, 230, 400, 70);
}

Результат: оба цыплёнка на месте — первый прямой, второй аккуратно наклонён. А зелёная земля внизу теперь лежит ровно по всей ширине холста, потому что после pop() система координат вернулась в исходное положение. Наклон остался заперт внутри пары push()/pop() и не вылез наружу.

Сравни два примера построчно. Код почти одинаковый — добавились только две строчки, push() перед трансформациями и pop() после фигуры. Но именно они превращают «весь холст поехал» в «наклонился только тот, кого я хотел». Запомни этот паттерн как заклинание: push() → translate/rotate/scale → рисуем → pop().

Пример 3: полянка из независимо повёрнутых цыплят

Теперь применим приём по-настоящему. Сделаем трёх цыплят в ряд, и каждому зададим свой наклон — будто они качают головами под музыку в разном ритме. Каждый цыплёнок — в своём пузыре push()/pop(), поэтому наклоны не складываются и не мешают друг другу.

function setup() {
  createCanvas(420, 240);
  noStroke();
}

function drawChick() {
  // рисуем цыплёнка в точке (0,0) — голова смотрит вверх-вправо
  fill(255, 209, 64);
  circle(0, 0, 60);          // тело
  fill(255, 140, 30);
  triangle(24, -4, 42, 0, 24, 4);  // клюв
  fill(40);
  circle(10, -10, 8);        // глаз
}

function draw() {
  background(135, 206, 235);

  let angles = [-0.4, 0, 0.4];   // три разных наклона
  let xs = [80, 210, 340];       // три позиции по горизонтали

  for (let i = 0; i < 3; i = i + 1) {
    push();                      // свой пузырь для каждого
    translate(xs[i], 130);       // переносим центр к i-му цыплёнку
    rotate(angles[i]);           // наклоняем только его
    drawChick();
    pop();                       // закрываем пузырь
  }
}

Результат: на голубом небе сидят три жёлтых CodeChick в ряд. Левый наклонил голову влево, средний смотрит прямо, правый завалился вправо — каждый со своим углом, как кадр из клипа, где птички качаются под бит. Несмотря на то что мы трижды вызвали translate и rotate, наклоны не сложились: каждый push() начинает с чистой сетки, а pop() её возвращает перед следующим витком цикла.

Обрати внимание на трюк: функцию drawChick() мы написали так, будто цыплёнок всегда в точке (0, 0). Это удобно — внутри пузыря translate уже перенёс начало координат куда надо, а rotate повернул. Цыплёнку «не важно», где он на холсте: он рисует себя вокруг нуля, а пузырь сам ставит и крутит его на месте. Так трансформации и функции работают в команде: одна рисует деталь «у себя дома», другие расставляют копии по сцене.

Пример 4: вложенные пузыри — рука внутри тела

Пузыри можно вкладывать друг в друга, как матрёшки. Это нужно, когда у объекта есть подвижная часть: например, цыплёнок наклонён целиком, а внутри него ещё и машет крылом. Внешний push() держит наклон всего тела, внутренний — добавляет поворот крыла поверх.

function setup() {
  createCanvas(300, 300);
  noStroke();
}

function draw() {
  background(135, 206, 235);

  push();                 // внешний пузырь — всё тело
  translate(150, 150);
  rotate(0.2);            // наклонили цыплёнка целиком

  fill(255, 209, 64);
  circle(0, 0, 90);       // тело
  fill(255, 140, 30);
  triangle(38, -5, 60, 0, 38, 5);  // клюв

  push();                 // внутренний пузырь — только крыло
  translate(-10, 5);
  rotate(-0.6);           // крыло повёрнуто ОТНОСИТЕЛЬНО тела
  fill(245, 190, 40);
  ellipse(0, 0, 50, 22);  // крыло
  pop();                  // закрыли крыло — наклон тела ещё держится

  pop();                  // закрыли тело — система координат чистая
}

Результат: крупный CodeChick в центре наклонён вправо целиком, а сбоку у него приподнятое крыло, повёрнутое под своим углом относительно тела. Когда мы поворачиваем крыло внутренним rotate(-0.6), оно крутится поверх наклона тела (0.2) — потому что трансформации внутреннего пузыря складываются с внешним. Внутренний pop() убирает только поворот крыла, оставляя наклон тела. Внешний pop() убирает и его.

Это и есть сила стека: пузыри вкладываются, и каждый pop() снимает ровно один слой — последний открытый. Так строят персонажей с подвижными частями, стрелки часов, ветки деревьев — всё, у чего есть «деталь внутри детали».

Подумай, насколько это упрощает жизнь. Без вложенных пузырей тебе пришлось бы вручную считать, под каким итоговым углом окажется крыло, если тело наклонено на 0.2, а крыло — ещё на -0.6 относительно него. Это тригонометрия и куча формул. А с push()/pop() ты просто описываешь каждую деталь «у себя дома» и складываешь повороты слоями — математику за тебя делает сам стек. Именно так устроены движки настоящих игр и мультфильмов: персонаж — это дерево вложенных систем координат, где плечо держит локоть, локоть держит кисть, а кисть держит пальцы, и каждый сустав крутится в своём пузыре.

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

  • push() без pop() (или наоборот). Если открыл push() и забыл pop(), лишний слой кальки остаётся на стеке. С каждым кадром draw() стек растёт, трансформации накапливаются — и картинка медленно уезжает или крутится сама по себе. Правило: на каждый push() ровно один pop(). Считай их парами, как открывающие и закрывающие скобки.

  • Перекрёстные пары вместо вложенных. Пузыри обязаны быть вложены, как матрёшки: push() push() pop() pop(). Нельзя «перекрестить»: открыть два, а закрыть в обратном порядке так, чтобы пары пересеклись. pop() всегда снимает последний открытый push() — рассчитывай порядок исходя из этого.

  • Думают, что push() запоминает картинку. Нет — push() запоминает только настройки: систему координат и стиль (fill, stroke, толщину). Уже нарисованные фигуры pop() не стирает и не двигает. Хочешь «откатить» рисунок — это делается не через pop(), а перерисовкой кадра (background() в начале draw()).

  • Ставят push() после трансформаций, а не до. Толку от push() нет, если ты уже накрутил translate/rotate до него — он запомнит уже перекошенное состояние. Закладку надо ставить раньше трансформаций, которые хочешь изолировать: сначала push(), потом translate/rotate/scale.

  • Забывают, что pop() возвращает и цвет тоже. Иногда это удивляет: поменял fill внутри пузыря, а после pop() заливка «сама» вернулась к прежней. Это не баг — push()/pop() хранят и стиль. Если нужно, чтобы цвет остался после пузыря, задавай его снова уже после pop().

Мини-практика: хоровод цыплят по кругу

Собери сцену, где цыплята стоят кольцом и каждый развёрнут «лицом» наружу, будто водят хоровод. Тут push()/pop() в цикле раскроется в полную силу. План такой:

  1. Возьми за основу Пример 3 с циклом, но вместо ряда расставь цыплят по кругу. Внутри цикла на каждом шаге считай угол let a = (TWO_PI / count) * i;.
  2. В пузыре сначала перенеси центр в середину холста, потом поверни на угол a, потом сдвинься на радиус наружу — примерно так: push(); translate(width/2, height/2); rotate(a); translate(120, 0); drawChick(); pop();.
  3. Проверь, что без pop() всё ломается: временно закомментируй pop() и посмотри, как цыплята слипаются в кучу и улетают — это наглядно покажет, зачем нужна пара.
  4. Усложни: добавь каждому цыплёнку внутренний пузырь с машущим крылом из Примера 4, и пусть угол крыла зависит от i, чтобы все махали по-разному.

Когда заработает, поэкспериментируй: поменяй count, поиграй радиусом во втором translate, попробуй добавить лёгкий scale() внутри пузыря, чтобы цыплята были разного размера. Главное — следи, чтобы каждый push() закрывался своим pop().

Итоги

Сегодня ты разобрался с сердцем всех трансформаций — стеком состояний:

  • Трансформации накапливаются: translate, rotate, scale меняют саму систему координат и действуют на всё, что нарисовано после них, до конца кадра.
  • push() кладёт текущее состояние холста (систему координат + стиль) на стек — как лист кальки поверх чертежа.
  • pop() снимает верхний слой и возвращает всё как было — наклоны и цвета внутри пузыря наружу не вылезают.
  • Пузыри вкладываются как матрёшки: каждый pop() закрывает последний открытый push(). Это позволяет рисовать объекты с подвижными частями.
  • Запомни паттерн: push() → трансформации → рисуем → pop(), и на каждый push() ровно один pop().

Теперь ты можешь крутить и двигать любую фигуру, не боясь, что за ней поедет вся остальная картинка — а это пропуск к сложным сценам из многих независимых деталей. В следующем уроке мы используем эти пузыри в цикле, чтобы строить рекурсивные узоры и фракталы: деревья, у которых из каждой ветки растут ветки поменьше, и снежинки, повторяющие сами себя. push()/pop() там станут твоим главным инструментом — так что закрепи их сегодня, поводи цыплят в хороводе, и до встречи на следующей странице!

Проверьте себя
1. Что именно запоминает push() в p5.js?
AУже нарисованную картинку, чтобы потом её стереть
BТекущую систему координат и настройки стиля (fill, stroke и т.д.)
CТолько цвет фона
DКоординаты мыши в этот момент
2. Почему в Примере 1 (без push/pop) зелёная земля оказалась наклонена вместе со вторым цыплёнком?
AИз-за бага в p5.js
BПотому что translate и rotate накопились и подействовали на всё, что нарисовано после них
CПотому что земля нарисована раньше цыплёнка
DПотому что у земли неправильный цвет
3. Сколько pop() должно приходиться на каждый push()?
AНи одного — pop() необязателен
BРовно один pop() на каждый push()
CДва pop() на каждый push()
DОдин pop() на весь скетч, в конце draw()
4. Если забыть pop() внутри draw(), что произойдёт со временем?
AНичего, p5.js сам всё исправит
BСкетч сразу выдаст ошибку и остановится
CЛишние слои копятся на стеке, трансформации накапливаются и картинка постепенно уезжает или крутится
DФон станет чёрным
5. Во вложенных пузырях push() push() ... pop() pop() — какой push() закрывает первый (внутренний) pop()?
AСамый первый, внешний push()
BПоследний открытый, то есть внутренний push()
CОба сразу
DЭто вызывает ошибку — вкладывать нельзя
6. Где правильно ставить push(), чтобы изолировать наклон одной фигуры?
AПосле translate и rotate, перед рисованием фигуры
BДо translate и rotate, которые нужно изолировать
CВ функции setup() один раз
DСразу после pop()