Шум Перлина: органическая случайность
Случайность бывает дёрганой, как помехи на старом телевизоре, а бывает плавной, как дрожание пламени свечи. Сегодня научимся второй.
Шум Перлина (noise) — это функция плавной случайности: соседние значения у неё близки друг к другу, поэтому переходы получаются органичными и мягкими, без резких скачков.
Зачем это нужно: случайность, которая не дёргается
Представь, что ты делаешь анимацию: цыплёнок CodeChick стоит на холсте и должен слегка покачиваться, будто его качает лёгкий ветерок. Логичная первая мысль — взять random() и каждый кадр сдвигать его на случайное число пикселей. Запускаешь — и видишь ужас: цыплёнок не качается, а трясётся в эпилептическом припадке, телепортируясь по всему холсту. Почему?
Потому что random() каждый кадр выдаёт совершенно несвязанное с прошлым число. В одном кадре +40, в следующем −38, потом +5, потом −47. Между соседними кадрами нет никакой связи — поэтому движение выглядит как дрожь, а не как покачивание. В прошлом уроке про random() и управляемый хаос мы как раз ловили эту дёрганость и боролись с ней костылями. Сегодня у нас есть правильный инструмент.
А теперь сравни с настоящим ветром. Когда ветер качает ветку, она не прыгает рывками — она плавно отклоняется в одну сторону, потом так же плавно возвращается и уходит в другую. Дым от свечи вьётся непрерывными струйками. Волны на воде катятся, а не моргают. Вся живая природа движется плавно-случайно: непредсказуемо, но без рывков. Вот эту плавную случайность и даёт нам noise() — функция шума Перлина.
К концу урока твой CodeChick будет мягко покачиваться, как на ветру, а холст за ним — переливаться плавными волнами. И всё это — одной маленькой функцией noise(), которую придумал художник по спецэффектам Кен Перлин ещё в 1983 году, чтобы текстуры в кино перестали выглядеть как телевизионные помехи. Поехали разбираться.
Метафора: два разных мешка с числами
Самый простой способ почувствовать разницу — представить два мешка, из которых ты достаёшь числа.
Первый мешок — это random(). Ты суёшь руку, вытаскиваешь бумажку с числом, смотришь, кладёшь обратно, перемешиваешь и тянешь снова. Каждая бумажка не помнит про предыдущую. Достал 0.9, потом 0.1, потом 0.7 — полный хаос, как при бросании кубика. Это идеально, когда тебе нужна именно непредсказуемость: раскидать перья, выбрать случайный цвет, кинуть монетку.
Второй мешок — это noise(). Представь длинную невидимую горную тропу, которая то поднимается на холм, то спускается в ложбину — плавно, без обрывов. Ты идёшь по ней шаг за шагом и на каждом шаге записываешь высоту под ногами. Соседние шаги почти на одной высоте — ведь ты не телепортируешься, а просто идёшь. Но если уйти далеко по тропе, высота станет совсем другой. Вот эта высота под ногами и есть значение шума.
Ключевая мысль: noise() — это не отдельные случайные числа, а единый плавный ландшафт. Ты не достаёшь число из мешка, а спрашиваешь высоту в конкретной точке этого ландшафта. Близкие точки дают близкую высоту.Поэтому у noise() есть то, чего нет у random(): аргумент-координата. Ты пишешь noise(t) и как бы говоришь: «какая высота тропы в точке t?». Спросишь noise(0.10) и noise(0.11) — получишь почти одинаковые числа, потому что точки рядом. Спросишь noise(0.10) и noise(8.30) — числа будут совсем разные, точки далеко друг от друга.
И ещё одна важная деталь, которая многих сбивает с толку: noise() в p5.js всегда возвращает число от 0 до 1. Не от −100 до 100, не в пикселях — а аккуратное дробное число вроде 0.42 или 0.78. Чтобы превратить его в пиксели, проценты или углы, мы будем растягивать этот диапазон функцией map(). Запомни это сразу — на этом спотыкается каждый второй новичок.
Пример 1. Видим разницу своими глазами
Лучший способ понять — нарисовать две дорожки точек рядом. Сверху пустим точки, чья высота берётся из random(), снизу — из noise(). Время будем гнать переменной t, которая чуть-чуть растёт каждый кадр.
let t = 0;
function setup() {
createCanvas(500, 300);
}
function draw() {
background(20, 20, 40);
let x = (frameCount % 500);
// верхняя дорожка: чистый random()
let yRandom = random(0, 120);
fill(255, 90, 90);
noStroke();
ellipse(x, 20 + yRandom, 6, 6);
// нижняя дорожка: noise()
let yNoise = noise(t) * 120;
fill(120, 220, 255);
ellipse(x, 170 + yNoise, 6, 6);
// двигаем точку наблюдения по тропе шума
t = t + 0.02;
}Результат: точки летят слева направо и оставляют след. Верхняя красная дорожка — рваная зубчатая каша: точки прыгают по всей высоте без всякой логики, как осыпь камней. Нижняя голубая дорожка — мягкая волнистая линия: точки плавно поднимаются и опускаются, рисуя гладкие холмы, будто кардиограмма спокойного сердца. Один и тот же «случайный» приём, а картинки противоположные.
Что здесь происходит по шагам
- Переменная
t— это наша позиция на горной тропе шума. В конце каждого кадра мы делаемt = t + 0.02— маленький шажок вперёд по тропе. random(0, 120)каждый кадр выдаёт ни с чем не связанное число от 0 до 120 — отсюда зубцы у красной дорожки.noise(t)возвращает высоту тропы в точкеt— число от 0 до 1. Мы умножаем его на 120, чтобы растянуть до высоты дорожки в пикселях. Соседние кадры дают соседниеt— отсюда плавность.- Шаг
0.02— это скорость, с которой мы идём по тропе. Чем он меньше, тем медленнее меняется высота и тем глаже волна.
Вот тот самый момент озарения: плавность шума целиком зависит от того, насколько маленькими шажками ты двигаешь аргумент. Поставь t = t + 0.5 вместо 0.02 — и голубая дорожка тоже станет дёрганой, ведь ты начнёшь прыгать по тропе огромными прыжками и каждый раз приземляться на совсем другую высоту. А поставь 0.005 — волна станет ленивой и тягучей. Это твой главный рычаг управления.
Пример 2. От 0..1 к настоящим пикселям через map()
В первом примере я схитрил и просто умножил шум на 120. Это работает, только когда тебе нужен диапазон от 0. А что, если цыплёнок должен качаться вокруг центра холста — то влево, то вправо от точки 250? Тут нужен честный инструмент — функция map(), которая переводит число из одного диапазона в другой.
let t = 0;
function setup() {
createCanvas(500, 300);
noStroke();
}
function draw() {
background(255, 245, 200);
// шум от 0..1 растягиваем в координату от 150 до 350
let x = map(noise(t), 0, 1, 150, 350);
let y = 150;
// тело цыплёнка
fill(255, 215, 0);
ellipse(x, y, 90, 90);
// клюв
fill(255, 140, 0);
triangle(x + 35, y, x + 60, y - 7, x + 60, y + 7);
// глаз
fill(0);
ellipse(x + 12, y - 14, 12, 12);
t = t + 0.01;
}Результат: на тёплом кремовом фоне жёлтый CodeChick с оранжевым клювом мягко скользит влево-вправо вокруг центра холста, словно лениво переминается с ноги на ногу. Он никогда не дёргается и не телепортируется — движение плавное и чуть непредсказуемое, будто живое. Доходит почти до края зоны, замедляется, передумывает и плавно уходит обратно.
Как читается строчка с map()
Запись map(noise(t), 0, 1, 150, 350) читается по-человечески так: «возьми число noise(t), которое живёт в диапазоне от 0 до 1, и пропорционально перенеси его в диапазон от 150 до 350». То есть когда шум равен 0, икс будет 150; когда шум 1 — икс 350; когда шум 0.5 — ровно посередине, 250.
Эти четыре числа после noise(t) идут парами: сначала откуда (0, 1 — родной диапазон шума), потом куда (150, 350 — нужный нам диапазон в пикселях). Перепутаешь пары местами — цыплёнок улетит не туда. Запомни порядок: что переводим, из какого диапазона, в какой диапазон.
Почему map() лучше, чем простое умножение? Потому что он гибкий и читаемый. Захочешь, чтобы цыплёнок качался по вертикали от 80 до 220, — просто поменяешь два последних числа на 80, 220. Захочешь сделать из шума угол поворота — переведёшь в диапазон 0, TWO_PI. Одна функция на все случаи, и в коде сразу видно, какой диапазон ты имел в виду.
Пример 3. Покачивание на двух координатах сразу
Настоящее живое движение — это качание не только влево-вправо, но и чуть вверх-вниз одновременно. Сделаем цыплёнку плавающее парение, как у воздушного шарика на ниточке. Тут есть тонкость: для x и для y нужно брать шум из разных мест тропы, иначе они будут меняться синхронно и движение получится по диагонали, как по линейке.
let t = 0;
function setup() {
createCanvas(400, 400);
noStroke();
}
function draw() {
background(200, 235, 255);
// для x берём шум в точке t
let x = map(noise(t), 0, 1, 120, 280);
// для y берём шум ДАЛЕКО на тропе: t + 1000
let y = map(noise(t + 1000), 0, 1, 120, 280);
fill(255, 215, 0);
ellipse(x, y, 80, 80);
fill(255, 140, 0);
triangle(x + 30, y, x + 52, y - 6, x + 52, y + 6);
fill(0);
ellipse(x + 10, y - 12, 10, 10);
t = t + 0.008;
}Результат: на нежно-голубом небе жёлтый цыплёнок мягко парит, выписывая плавные неровные петли по центру холста — будто его лениво носит тёплый воздух. Движение по горизонтали и вертикали не совпадает, поэтому траектория получается живой и непредсказуемой, а не прямой диагональю. Цыплёнок никогда не повторяет один и тот же путь дважды.
Зачем сдвигать второй аргумент на 1000
Смотри внимательно на хитрость с noise(t + 1000). Если бы мы для x и для y взяли noise(t) с одним и тем же аргументом, обе координаты возвращали бы одно и то же число в каждый момент. Цыплёнок двигался бы строго по диагонали из угла в угол — скучно и неестественно.
Добавив + 1000 ко второму вызову, мы как бы говорим: «а высоту для y посмотри в совсем другом месте тропы, за километр отсюда». Там тропа идёт сама по себе, никак не связана с участком возле t. Поэтому x и y меняются независимо, и движение становится живым. Число 1000 ничем не волшебно — подошло бы и 500, и 9999. Главное, чтобы участки тропы были далеко друг от друга и не пересекались.
Заметь ещё, что шаг я уменьшил до 0.008. Чем медленнее мы крадёмся по тропе, тем нежнее и спокойнее парит цыплёнок. Поставь 0.03 — и он начнёт метаться куда живее, будто разнервничался.
Частые ошибки и подводные камни
1. Забывают, что noise() даёт только 0..1
Самая частая беда. Пишут let x = noise(t) и удивляются, почему цыплёнок прилип к левому верхнему углу и не двигается. А он двигается — но в пределах одного пикселя, ведь шум скачет между 0 и 1, а это меньше пикселя. Всегда растягивай шум через map() или умножение до нужного диапазона.
2. Делают слишком большой шаг по тропе
Если двигать аргумент крупными шагами вроде t = t + 1, ты перепрыгиваешь через целые холмы и впадины тропы и приземляешься каждый раз на несвязанную высоту. Результат — та же дёрганость, что у random(), и весь смысл шума теряется. Держи шаг маленьким: обычно от 0.005 до 0.05.
3. Берут для x и y один и тот же аргумент
Если x и y оба считаются из noise(t), они всегда равны друг другу, и объект ползёт строго по диагонали. Давай каждой координате свой участок тропы: для одной noise(t), для другой noise(t + 1000). Это правило работает и для любых других независимых величин — цвета, размера, угла.
4. Путают noise() с random() по смыслу
Шум — не замена случайности на все случаи жизни. Если тебе нужно раскидать перья по холсту в разные стороны или выбрать случайный цвет один раз — бери random(), он для этого и создан. Шум нужен там, где важна плавность во времени или в пространстве: движение, дрожание, волны, текстуры. Выбирай инструмент под задачу.
5. Ждут одинаковую картинку при каждом запуске
p5.js при старте слегка перемешивает ландшафт шума, поэтому одна и та же программа может дать чуть разную волну в разные запуски. Если тебе нужна повторяемость — например, чтобы текстура была одинаковой каждый раз, — поставь в начале noiseSeed(42) с любым числом-сидом. Тогда тропа шума будет каждый раз ровно той же, как и с randomSeed() для обычной случайности.
Мини-проект: травинки на ветру
Собери поле травы, которое колышется на ветру, и посади в него своего CodeChick. Это классическое применение шума, и оно выглядит почти волшебно. База — пример 2, доделай сам:
- В цикле
forнарисуй вдоль нижнего края холста штук 40 травинок — вертикальных линий командойline(), растущих снизу вверх. - Нижний конец каждой травинки прибит к земле, а верхний пусть отклоняется по горизонтали на величину из шума:
let sway = map(noise(i * 0.2 + t), 0, 1, -25, 25), гдеi— номер травинки. - Каждый кадр увеличивай
tна0.01— и вся трава дружно заколышется, причём соседние травинки будут наклоняться похоже (ведь их аргументы шума рядом), как настоящая волна по полю. - В центр поля посади покачивающегося цыплёнка из примера 2.
- Усложни: сделай цвет неба тоже плавающим через
noise()— пусть он медленно перетекает от утреннего к дневному. Или добавь облако, которое лениво дрейфует по шуму через весь холст.
Когда заработает, обязательно поиграй с двумя числами: множителем 0.2 (как быстро меняется наклон от травинки к травинке) и шагом t (как быстро дует ветер). Это твоё поле — устрой на нём бурю или штиль.
Итоги
Сегодня ты добавил в свой арсенал инструмент, который превращает мёртвую дёрганую случайность в живое плавное движение. Главное, что стоит унести:
random()иnoise()— два разных мешка. Первый даёт несвязанные числа (резко, хаотично), второй — плавный ландшафт, где близкие точки дают близкие значения.noise()всегда возвращает число от 0 до 1. Чтобы получить пиксели, проценты или углы, растягивай его черезmap(noise(t), 0, 1, откуда, куда).- Плавность зависит от величины шага аргумента: маленький шаг (0.005–0.05) — мягкая волна, большой — снова дёрганость.
- Для независимых величин (x и y, цвет, угол) бери шум из разных участков тропы:
noise(t)иnoise(t + 1000). - Нужна повторяемость — зафиксируй ландшафт через
noiseSeed(число).
Шум Перлина — это секретный соус почти всей органической графики: летящий дым, бегущие облака, рябь на воде, рельеф гор в играх, дрожание огня. Стоит почувствовать его на кончиках пальцев — и твои работы перестанут выглядеть «компьютерными» и начнут дышать. В следующем уроке мы поднимем шум на новый уровень: научимся спрашивать высоту тропы не в одной точке, а на целой плоскости — noise(x, y) — и нарисуем настоящий рельеф, дым и текстуры. До встречи в коде!