DEV/MATH[ V0.0.1-ALPHA ]//articles/noise

Шумы Перлина и Simplex: как из чисел рождаются ландшафты, облака и текстуры

Всем привет! Меня зовут Гриша Дядиченко, и я технический директор и основатель White Label Games. Уже больше десяти лет работаю с компьютерной графикой, AR/VR и компьютерным зрением — в основном заказная разработка, иногда собственные прототипы по выходным.

В какой-то момент я довольно много генерил процедурные миры и возился с VFX — и регулярно ловил один и тот же неприятный эффект: то рельеф выходит «пластмассовый» и одинаковый, то облако мерцает каждый кадр, то текстура камня выглядит как мусор. Забавно, что ровно от «пластмассового» вида в своё время отталкивался и Кен Перлин, когда в 1983-м придумывал свой шум. Но об этом чуть позже.

Сталкивались ли вы с мыслью, что гора в вашей любимой игре — это, вообще-то, не нарисованная художником карта высот и не скан реальности? Когда вы летите над бесконечным миром Minecraft или смотрите на объёмные облака в Horizon Zero Dawn — под ними нет «карты», лежащей на диске. Есть гладкая функция от координат: подаёте ей (x,y)(x, y) — получаете высоту рельефа в этой точке; подаёте (x,y,z)(x, y, z) — плотность облака. Гору никто не лепил руками: кто-то выбрал seed и пару параметров.

И тут возникает резонный вопрос: казалось бы, чтобы получить «природную случайность», достаточно взять random() на каждую точку. Да? Конечно же нет. Так получается белый шум — телевизионная «снежинка», из которой не построить ничего. Весь фокус не в случайности, а в гладкой случайности. С неё и начнём.

Чтож, давайте по порядку. В шести частях: почему random не годится и что такое гладкий шум; как устроен шум Перлина — случайные градиенты в узлах решётки, скалярные произведения и интерполяция; как из одной октавы складыванием (fBm) рождается рельеф; что такое domain warping и как из шума получается живой огонь; как делают объёмные облака через raymarch и Perlin-Worley; и наконец — артефакты классического Перлина, Simplex как ответ на них и какой шум выбирать. В каждой части — интерактив прямо в браузере, включая настоящие шейдеры с объёмным облаком и огнём, которые можно облететь камерой.

Если тема интересна — добро пожаловать.

[больше интересного в телеграм]

Часть 1. Гладкая случайность: почему random не годится

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

Выйдет белый шум. Та самая телевизионная «снежинка»: каждый пиксель независим от соседей, никакой структуры. Для текстуры помех или зерна плёнки — отлично. Для ландшафта — мусор: нет ни холмов, ни долин, один хаос. Представьте рельеф Minecraft, собранный из такого «снега»: соседние блоки высоты ничем не связаны, и вместо холмов выходит равномерная каша, в которую невозможно играть. Соседние точки обязаны быть похожи (рельеф непрерывен — гора не телепортируется на метр вбок), а у белого шума этой связи нет вообще.

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

Первая попытка — value noise

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

Уже гораздо лучше белого шума: появляются плавные перепады, что-то похожее на холмы. Но есть беда — сквозь картинку проступает сама решётка: блочные артефакты, выровненные по осям. Глаз цепляется за «квадратики», и природным это не выглядит.

Здесь и появляется шум Перлина. Кен Перлин придумал его в 1983-м — бесился ровно от «пластмассового» вида графики, над которой работал для «Трона» (1982), и захотел добавить управляемую естественную случайность. За эту функцию он позже получил технический «Оскар» (Academy Award for Technical Achievement, 1997) — да, за математическую функцию. Класс, к которому она относится, называется gradient noise: в узлах лежат не значения, а случайные направления-градиенты. Как именно это убирает блочность — разберём в части 2.

А пока — зоопарк. Один и тот же seed, четыре способа сделать «случайность»:

Покрутите масштаб и seed. Белый шум — снег, из него ничего не вырастет. Value noise глаже, но видна сетка. Перлин ещё глаже, без явной решётки. А fBm (это уже забегая вперёд, в часть 3) добавляет деталь на каждом масштабе — именно он похож на настоящий рельеф.

Собственно, отсюда и план: сначала научимся делать одну гладкую октаву (Перлин), потом — складывать октавы в рельеф, а дальше уже облака, огонь и тонкости.

[больше интересного в телеграм]

Часть 2. Шум Перлина: случайные градиенты в узлах

Итак, value noise лезет сеткой, потому что в узлах лежат значения — этакие случайные «пятна», которые глаз и считывает как решётку. Перлин зашёл с другой стороны. Идея на первый взгляд странная: давайте в узлах хранить не числа, а случайные направления.

Что лежит в узле

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

Почему скалярное произведение даёт именно это — по шагамОсторожно! Математика!

Давайте разберём само скалярное произведение — тут на нём всё и держится. Напомню, что оно вообще делает. Для двух векторов

gd=gdcosθ,\vec g \cdot \vec d = |\vec g|\,|\vec d|\cos\theta,

где θ\theta — угол между ними. По сути это проекция: мера того, насколько один вектор смотрит в ту же сторону, что и другой. Сонаправлены — число большое и положительное; перпендикулярны — ровно ноль; смотрят врозь — отрицательное.

Теперь подставим наши векторы. Градиент g\vec g — единичный, это чистое направление: «куда от этого угла идёт подъём». Смещение d=pc\vec d = p - c — «куда и насколько мы отошли от угла к точке». А раз градиент единичный, gd=dcosθ\vec g \cdot \vec d = |\vec d|\cos\theta — и это буквально то, как далеко точка ушла вдоль стрелки-градиента. По шагам, что отвечает каждый угол:

  • идём точно по стрелке — число положительное, и тем больше, чем дальше отошли (забираемся в «гору», которую назначил этот угол);
  • идём против стрелки — отрицательное (спускаемся в «низину»);
  • идём поперёк стрелки — ноль: на такое смещение угол вообще не реагирует.

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

// 2D-шум Перлина (суть). p — точка внутри клетки сетки.
// g00..g11 — случайные градиенты в 4 углах клетки.
float n00 = dot(g00, p - c00);   // скалярное произведение
float n10 = dot(g10, p - c10);   // градиента на смещение
float n01 = dot(g01, p - c01);
float n11 = dot(g11, p - c11);

vec2  f   = fade(fract(p));       // гладкая кривая, НЕ линейная
float nx0 = mix(n00, n10, f.x);
float nx1 = mix(n01, n11, f.x);
float n   = mix(nx0, nx1, f.y);   // одно число шума в [-1, 1]

И вот следствие, ради которого всё затевалось: ровно в узле вектор «от угла к точке» нулевой — значит, и шум там строго ноль. Никаких «пятен», за которые цеплялся глаз у value noise, — решётка перестаёт проступать.

Почему fade, а не линейная интерполяция

Если смешивать четыре скалярных произведения линейно, на границах клеток будут заметные изломы — производная скачет, и стыки выдают себя. В улучшенной версии 2002 года («Improving Noise», SIGGRAPH) Перлин берёт квинтику

fade(t)=6t515t4+10t3\text{fade}(t) = 6t^5 - 15t^4 + 10t^3

вместо старой кубики 3t22t33t^2 - 2t^3. У квинтики на концах нулевые и первая, и вторая производные — поэтому стыки клеток гладкие до второй производной, и швов не видно. Там же, в 2002-м, он заменил «бросаемые» случайные градиенты на фиксированный набор из 12 направлений (к серединам рёбер куба), чтобы их распределение было ровнее. Сам шум, кстати, он впервые опубликовал ещё в 1985-м — статья «An Image Synthesizer», SIGGRAPH.

В демке ниже это видно руками: красные точки — узлы, зелёные стрелки — их градиенты. Переключите интерполяцию с квинтики на линейную — и на границах клеток вылезут швы.

Детерминизм: тот же seed — тот же мир

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

Именно поэтому seed в Minecraft воспроизводит у всех один и тот же мир, а в дейли-челленджах все играют на одной карте. Случайность здесь — иллюзия: вы просто не знаете seed. По сути шум Перлина — это чистая детерминированная функция: захешировали узлы в градиенты, посчитали скалярные произведения, сгладили квинтикой.

Так что одна октава Перлина — это пологие мягкие холмы. Красиво, но скучновато: настоящих гор и мелкой шероховатости тут нет. Чтобы из этого получился рельеф, шум складывают сам с собой на разных масштабах. Об этом — дальше.

[больше интересного в телеграм]

Часть 3. fBm: складываем октавы в рельеф

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

Приём №1 — fBm

fBm (fractal Brownian motion) — это сумма нескольких октав одного и того же шума. На каждой октаве частоту умножаем на 2 (это lacunarity), а амплитуду — на 0.5 (это gain, он же persistence). Крупная октава задаёт общую форму, мелкие добавляют деталь.

float fbm(vec2 p) {
  float sum = 0.0, amp = 0.5, freq = 1.0;
  for (int i = 0; i < 6; i++) {
    sum  += amp * noise(p * freq);
    freq *= 2.0;   // lacunarity — частота вдвое выше
    amp  *= 0.5;   // gain — амплитуда вдвое меньше
  }
  return sum;
}

4–6 октав — и пологая «капля» превращается в правдоподобный горный рельеф. Lacunarity 2.0 и gain 0.5 — классические значения, но именно их и стоит крутить: больше gain — рельеф «шумнее» и острее, меньше — глаже.

Покрутите в демке число октав от одной до восьми и посмотрите, как на глазах собирается рельеф. Lacunarity и gain — рядом.

Приём №2 — формовка под материал

Один и тот же fBm можно по-разному «формовать», и получится разный материал.

  • abs(noise), сложенный по октавам, даёт острые гребни вместо пологих холмов — это ridged noise, горы со скалами. Переключатель fBm/ridged в демке как раз про это.
  • 3D-шум, обрезанный порогом, — это пещеры (в Minecraft пещеры считаются именно из 3D-шума).
  • sin(x + turbulence(p)) — прожилки мрамора. Тут turbulence это сумма abs(noise) по октавам; и да, это буквально мраморная текстура Перлина ещё из статьи 1985 года.

То есть базовый шум один, а горы, пещеры и мрамор — это вопрос того, как мы его суммируем и обрезаем. No Man's Sky с его бесконечными планетами и Minecraft с рельефом и пещерами стоят ровно на этой надстройке над одной гладкой функцией.

Как НЕ надо

Чтоб не выглядело, будто всё всегда получается, — пара типичных граблей. Если взять value noise или мало октав, выйдут «пузыри» без деталей. Если хеш градиентов плохой — сквозь шум снова проступит сетка. А если сэмплить «облако» через random() на пиксель, оно будет мерцать каждый кадр (это же белый шум). Рецепт устойчивый: гладкий шум + фрактальные октавы, а сверху — искажение координат. Вот про искажение и поговорим дальше — заодно сделаем из шума огонь.

[больше интересного в телеграм]

Часть 4. Domain warping: как из шума получается огонь

fBm уже даёт убедительный рельеф, но гребни у него выходят прямоватые, «причёсанные». Природа так не делает: дым завивается, лава течёт языками, мрамор идёт прожилками. Чтобы получить эту органику, есть отдельный приём — domain warping, искажение области. Популяризировал его Иниго Килез (тот самый, с iquilezles.org, и заодно сооснователь Shadertoy).

Идея в одну строку

Подаём шум на вход самому шуму. Вместо того чтобы сэмплить fbm(p)\text{fbm}(p), сначала искажаем координаты другим шумом:

fbm(p+fbm(p))\text{fbm}\big(p + \text{fbm}(p)\big)

То есть перед выборкой мы сдвигаем точку pp на величину, которую сами же насчитали шумом. Прямые гребни начинают завихряться, и картинка становится живой. Можно вложить ещё глубже — fbm(p+fbm(p+fbm(p)))\text{fbm}(p + \text{fbm}(p + \text{fbm}(p))) — будет ещё сильнее. По сути это вся разница между «ровным» процедурным узором и облаком или мрамором: одна строка, в которой шум искажает сам себя.

Огонь — это не частицы

Теперь соберём из этого огонь. Многие по привычке думают, что огонь в играх — это система частиц. Бывает и так, но «шейдерный» огонь делается иначе и часто дешевле. Рецепт такой:

  • берём turbulence (сумму abs октав) — она даёт «языки» с острыми верхушками, а не мягкие холмы;
  • прокручиваем координаты вверх по времени (p.y += time * speed) — шум «течёт» снизу вверх, как восходящий поток;
  • добавляем domain warping — и языки начинают извиваться, а не просто ползти;
  • гасим интенсивность маской снизу вверх (внизу жарко, вверху затухает);
  • и прогоняем результат через тёпловую палитру: чёрный → тёмно-красный → оранжевый → жёлтый → белый по возрастанию интенсивности.

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

Демка ниже — ровно этот рецепт, только объёмный: пламя живёт в 3D, и его можно обойти камерой (тащите мышью или пальцем). Главный тумблер тут — Domain warp. Поставьте его в ноль: увидите «слоистый» восходящий шум, скучный и явно процедурный. Поднимайте — и из той же функции вырастают живые языки. Палитру можно переключить на плазму или дым — математика под ней одна и та же.

Обратите внимание: при малом числе октав огонь «крупный» и ламповый, при большом — детальный и злой. Скорость крутит, насколько быстро поток течёт вверх; «высота/раздув» — насколько далеко язык дотягивается. Это, конечно, упрощённый огонь — без физики горения и без свечения окружения, но как иллюстрация связки «turbulence + warping + палитра» он показывает суть.

Так что мрамор, дерево, лава и огонь — это всё та же надстройка над базовым шумом, просто с искажением координат и разной палитрой. А в следующей части возьмём шум в 3D и поднимемся в небо — за облаками.

[больше интересного в телеграм]

Часть 5. Объёмные облака: raymarch сквозь шум

До сих пор шум у нас был плоский — функция от (x,y)(x, y). Но облако — это объём: у него есть плотность в каждой точке пространства. Значит, нужен шум от (x,y,z)(x, y, z), а вместо «нарисовать пиксель» — пройти лучом сквозь объём и собрать, сколько облака он проткнул. Это и есть raymarch по объёму.

Перлин-Worley: форма плюс «цветная капуста»

Тут вылезает проблема: одного Перлина для облака мало. Он даёт мягкие «клубы» — общую форму, — но настоящему облаку нужна ещё мелкая ячеистая деталь по краям, та самая «цветная капуста». За эту ячеистость отвечает другой шум — Worley (он же cellular noise, Steven Worley, «A Cellular Texture Basis Function», SIGGRAPH 1996). Идея Worley простая: разбрасываем по пространству случайные точки и в каждой точке считаем расстояние до ближайшей из них. Получается узор из ячеек — то, что нужно для бугристого края.

В Horizon Zero Dawn эти два шума смешали в один — Perlin-Worley (Andrew Schneider и Nathan Vos, «The Real-time Volumetric Cloudscapes of Horizon: Zero Dawn», SIGGRAPH 2015). Низкочастотный перлиноподобный шум задаёт форму, высокочастотный Worley «выгрызает» край. Разные шумы — это просто инструменты, которые комбинируют под задачу.

Как луч собирает облако

Алгоритм raymarch по облаку выглядит так:

  • из камеры через каждый пиксель пускаем луч и находим, где он входит в объём облака и где выходит (в демке объём ограничен сферой — это одно облако, а не бесконечная пелена, поэтому луч делает мало шагов);
  • идём вдоль луча небольшими шагами; на каждом шаге считаем плотность — это Perlin-Worley, ремапнутый по параметру «покрытие» (coverage), помноженный на мягкий спад к краю облака;
  • там, где плотность есть, делаем несколько шагов к солнцу и копим, сколько облака на пути к свету; чем больше — тем темнее эта точка. Это поглощение по закону Бера-Ламберта: T=eρdT = e^{-\rho\, d};
  • складываем цвет спереди назад, пока луч не «упрётся» в насыщенную непрозрачность — тогда выходим раньше (early-out).

Coverage двигает порог: при низком — редкие облачка, при высоком — сплошная пелена. Ветер просто дрейфует координаты шума по времени. А число шагов raymarch — это прямой рычаг «качество против fps»: больше шагов — глаже облако и дороже кадр.

Облёт камерой (тащите мышью/пальцем) — лучший способ почувствовать, что это настоящий объём, а не картинка: со всех сторон облако выглядит одинаково убедительно. Покрутите покрытие и угол солнца — облако подсвечивается с нужной стороны, появляется объём. Тумблер «Детализация» включает тот самый высокочастотный Worley: с ним край становится «цветной капустой», без него облако более гладкое и дешёвое. Если на вашем железе тяжеловато — убавьте число шагов raymarch, это самый прямой способ вернуть кадры (на мобиле так и делают).

Конечно же, продакшен-облака уровня HZD сложнее: там и многослойные текстуры шума, запечённые заранее, и хитрое рассеяние света, и оптимизации на полкадра. Но фундамент ровно этот — Perlin плюс Worley плюс raymarch с поглощением. В следующей части закроем тему изнанкой: откуда у Перлина артефакты, при чём тут Simplex и какой шум вообще выбирать.

[больше интересного в телеграм]

Часть 6. Simplex и какой шум выбрать

Теперь — грабли, на которые наступают, когда пишут генерацию на проде. У классического шума Перлина есть два изъяна.

Изъян №1 — артефакты по осям

Шум Перлина построен на квадратной сетке (в 3D — на кубической). Из-за этого в нём проступает лёгкий перекос вдоль осей X, Y, Z — «капли» чуть выравниваются по решётке. На пологом рельефе вы этого не заметите, а вот на гладкой поверхности или в анимации — уже видно, картинка как будто «причёсана» по осям.

Изъян №2 — взрывная сложность

Чтобы посчитать значение, классический шум интерполирует по всем углам ячейки-гиперкуба. А их 2n2^n: 4 угла в 2D, 8 в 3D, 16 в 4D. Третье измерение — это уже не карта высот, а объём: так в Minecraft из 3D-шума вырезают пещеры. Четвёртое берут не от хорошей жизни — это анимированный объёмный шум, где время идёт как ось ww (те же облака, которые должны жить во времени). И вот тут 2n2^n начинает больно кусаться.

Решение — Simplex

Simplex-шум Перлин предложил сам в 2001-м. Идея: заменить квадрат на симплекс — простейшую фигуру, которой можно замостить пространство. В 2D это треугольник, в 3D — тетраэдр. У симплекса углов всего n+1n+1:

Канонический разбор алгоритма, по которому его все и реализуют, — Stefan Gustavson, «Simplex noise demystified» (2005). В демке ниже Перлин и Simplex сэмплируются по одним и тем же координатам. Включите подсветку артефактов — у Перлина слева проступит решётчатый перекос, у Simplex справа картинка ровнее.

История с патентом и OpenSimplex

Тут есть поворот. Перлин запатентовал свою реализацию Simplex-шума (US 6 867 776) — а значит, повторять именно её было нельзя. И обход оказался геометрическим — в самой решётке. Вспомните, как Simplex получает свою сетку: берёт обычную кубическую решётку и сплющивает её скосом-преобразованием в симплексы (треугольники, тетраэдры). OpenSimplex Курта Спенсера (KdotJPG, 2014) приходит к симплексам с другой стороны — строит шум на растянутом гиперкубическом honeycomb (растяжение вместо сплющивания). Решётка другая — значит, и запатентованное построение не повторяется, хотя на выходе тот же класс гладкого gradient noise, да ещё и с меньшими осевыми артефактами. Это и есть та самая «патентно-чистая альтернатива», на которой несколько лет сидели геймдевы, кому нужен был шум без артефактов, но без юридических рисков. Патент истёк 8 января 2022 года — Simplex теперь свободен, но OpenSimplex прижился: многим он просто нравится визуально.

Какой шум выбирать

И вот тут — без фанатизма. Не усложняйте на пустом месте:

  • для рельефа, лёгкой вариативности текстуры и прочих «земных» задач хватает классического Перлина или даже value noise — артефактов вы там не увидите;
  • Simplex стоит брать, когда уходите в 3D/4D (анимированный объёмный шум), где 2n2^n взрывается, или когда перекос по осям реально лезет на гладкой поверхности;
  • а для облаков, как мы видели, один шум вообще не выбирают — комбинируют Перлин с Worley.

Так что выбирайте шум по размерности и допуску на артефакты, а не по моде. Ну и помните: всё это — варианты одной идеи. Гладкая случайность из решётки, которую складывают октавами, искажают и обрезают порогом.

[больше интересного в телеграм]

Что в итоге

Чтож, давайте соберём картину. Мы начали с того, что рельеф, облака и текстуры в играх — это не нарисованные карты, а значения гладкой функции от координат. Прошли путь: белый шум бесполезен → гладкая случайность через value noise → шум Перлина на случайных градиентах с квинтикой и детерминизмом по seed → fBm, который складывает октавы в рельеф → domain warping, из которого получается огонь → объёмные облака через raymarch и Perlin-Worley → и наконец Simplex как ответ на артефакты и сложность классики.

По сути всё это — варианты одной идеи: взять гладкую случайность из решётки и формовать её — складывать, искажать, обрезать порогом, смешивать с другим шумом. Гору в вашем любимом тайтле никто не лепил руками; кто-то выбрал seed и пару параметров.

Конечно, многое осталось за кадром. Я почти не трогал 3D/4D-детали и то, как шум запекают в текстуры на GPU ради скорости; не разбирал эрозию (рельеф из чистого fBm всё-таки «пухлый», по-настоящему гор без симуляции воды не выходит); упростил освещение облаков и физику огня. Это всё — поводы для отдельных разборов, и если будет интерес — сделаем.

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

[больше интересного в телеграм]
// нужна помощь в проекте?

Консультации и техническое сопровождение

Геймдев, графика, AR/VR и computer vision — с математикой как ядром. Разбор вашей задачи и предложение по формату — бесплатно.

Оставить заявку →