Процедурная генерация и Шум Перлина(Perlin Noise) в Unity

Tulenber 17 April, 2020 ⸱ Intermediate ⸱ 9 min ⸱ 2019.3.9f1 ⸱

Продолжение цикла о создании бесконечных миров, в которой мы познакомимся с основами процедурной генерации.

Кажется что довольно большая часть людей приходит в игровую индустрию из творческих побуждений. Одним из направлений такого творчества может послужить создание собственных миров, либо похожих на наш, либо сильно отличающихся. Больше того, если обратиться к интернету с вопросом по этой тематике, то найдётся довольно большое количество статей, что может служить подтверждением большого стремления людей стать демиургами. Этот пост пойдёт по стопам многих предшественников, и расскажет об одном из самых простых и базовых способов создать небольшой, но всё-таки свой кусочек вселенной, а именно “Шуме Перлина(Perlin Noise)”.

Процедурная генерация

С точки зрения программиста, для создания любого предмета есть 2 пути: сделать его руками или написать код, для его генерации. Хотя скорее есть только второй, а первым почему-то пользуются все остальные. Для начала определимся с базовыми понятиями:

  • Процедурная генерация (ПГ, англ. Procedural generation, сокр. PCG) - общее название для механизмов автоматического создания контента по какому-либо алгоритму. Это понятие включает в себя создание всего от музыки, до правил игры.
  • Генератор псевдослучайных чисел (ГСПЧ, англ. pseudorandom number generator, сокр. PRNG) - алгоритм, порождающий последовательность чисел, элементы которой почти независимы друг от друга и подчиняются заданному распределению.
  • Порождающий элемент(англ. seed) - определяет последовательность выдаваемых ГСПЧ чисел.

Если вам необходимо небольшое количество контента, то, конечно же, самым простым способом будет создать его руками(расставить по карте деревья или нарисовать текстуру). Однако если мы говорим про действительно большие объёмы материала, то процедурная генерация может стать более выгодным способом. Принцип довольно прост, когда вам необходимо что то, то используя ГСПЧ, порождающий элемент, дополнительные входные данные и какой-либо алгоритм создания необходимой вещи вы получаете уникальную единицу контента. Если после генерации происходят какие-либо изменения, то вы их сохраняете отдельно. Впоследствии, если вам необходимо повторить сгенерированный объект, вы, зная порождающий элемент, входные данные и внесённые изменения можете повторить его генерацию и получить данный объект.

Это общее описание схемы, которая при использовании различных алгоритмов подходит практически для любой задачи начиная от генерации музыки, продолжая текстурами огня, заканчивая генерацией животных для какой-нибудь планеты в No Man’s Sky. Мы же в данном посте рассмотрим применение процедурной генерации к пространству, а именно к поверхности земли, так что все дальнейшие примеры будут сведены к этой задаче.

Сгенерируем свои 6 соток

Одним из универсальных способов описания земной поверхности является применение карты высот. Она представляет из себя двумерный массив с данными о высоте точек на местности. Если же мы возьмём вертикальный срез с такой карты, то получим вот такой вид:
Elevation Profile

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

В двухмерном виде, сгенерированная карта будет выглядеть примерно так:
White noise

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

Шум Перлина

Для генерации связанных данных нам поможет ГСПЧ под названием Шум Перлина, который при генерации выдаст подобную картину:
Perlin noise

Мы не будем углубляться в принципы работы самого алгоритма, за дополнительной информацией можно обратиться к этой англоязычной статье или этой статье с хабра, в которой рассказывается про генерацию облаков.

Понятия, которые нам понадобятся для дальнейшей работы

  • Амплитуда (amplitude) - максимальное значение смещения или изменения переменной величины от среднего значения.
  • Частота (frequency) - характеристика периодического процесса, равна количеству повторений или возникновения событий (процессов) в единицу времени.
  • Лакунарность (lacunarity) - контролирует изменение частоты.
  • Постоянство (persistence) - контролирует изменение амплитуды.
  • Октава (octave) - повторение.

Base

Продемонстрируем принцип работы алгоритма с тремя октавами:
Lacunarity = 2;
Persistence = 0.5;

Первая октава задаёт основной перепад высот(моря-горы)
Frequency = Lacunarity ^ 0 = 1
Amplitude = Persistence ^ 0 = 1
Octave one

Вторая октава задаёт средние перепады поверхности(овраги-валуны)
Frequency = Lacunarity ^ 1 = 2
Amplitude = Persistence ^ 1 = 0.5
Octave two

Третья октава задаёт малые перепады поверхности(мелкие камни)
Frequency = Lacunarity ^ 2 = 4
Amplitude = Persistence ^ 2 = 0.25
Octave three

Накладываем октавы друг на друга и в результате получаем уже что-то похожее на реальную карту высот в разрезе:
Result

Реализация

Базой для данной реализации послужила серия видео "Procedural Terrain Generation" от Sebastian Lague.

Создадим 3 скрипта

  1. NoiseMapGenerator - отвечает за генерацию карты высот с помощью Шума Перлина
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
using UnityEngine;

public static class NoiseMapGenerator
{
    public static float[] GenerateNoiseMap(int width, int height, int seed, float scale, int octaves, float persistence, float lacunarity, Vector2 offset)
    {
        // Массив данных о вершинах, одномерный вид поможет избавиться от лишних циклов впоследствии
        float[] noiseMap = new float[width*height];

        // Порождающий элемент
        System.Random rand = new System.Random(seed);

        // Сдвиг октав, чтобы при наложении друг на друга получить более интересную картинку
        Vector2[] octavesOffset = new Vector2[octaves];
        for (int i = 0; i < octaves; i++)
        {
            // Учитываем внешний сдвиг положения
            float xOffset = rand.Next(-100000, 100000) + offset.x;
            float yOffset = rand.Next(-100000, 100000) + offset.y;
            octavesOffset[i] = new Vector2(xOffset / width, yOffset / height);
        }

        if (scale < 0)
        {
            scale = 0.0001f;
        }

        // Учитываем половину ширины и высоты, для более визуально приятного изменения масштаба
        float halfWidth = width / 2f;
        float halfHeight = height / 2f;

        // Генерируем точки на карте высот
        for (int y = 0; y < height; y++)
        {
            for (int x = 0; x < width; x++)
            {
                // Задаём значения для первой октавы
                float amplitude = 1;
                float frequency = 1;
                float noiseHeight = 0;
                float superpositionCompensation = 0;

                // Обработка наложения октав
                for (int i = 0; i < octaves; i++)
                {
                    // Рассчитываем координаты для получения значения из Шума Перлина
                    float xResult = (x - halfWidth) / scale * frequency + octavesOffset[i].x * frequency;
                    float yResult = (y - halfHeight) / scale * frequency + octavesOffset[i].y * frequency;

                    // Получение высоты из ГСПЧ
                    float generatedValue = Mathf.PerlinNoise(xResult, yResult);
                    // Наложение октав
                    noiseHeight += generatedValue * amplitude;
                    // Компенсируем наложение октав, чтобы остаться в границах диапазона [0,1]
                    noiseHeight -= superpositionCompensation;

                    // Расчёт амплитуды, частоты и компенсации для следующей октавы
                    amplitude *= persistence;
                    frequency *= lacunarity;
                    superpositionCompensation = amplitude / 2;
                }

                // Сохраняем точку для карты высот
                // Из-за наложения октав есть вероятность выхода за границы диапазона [0,1]
                noiseMap[y * width + x] = Mathf.Clamp01(noiseHeight);
            }
        }

        return noiseMap;
    }
}
  1. NoiseMap - связывает генератор и рендерер
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
using UnityEngine;

public enum MapType
{
    Noise,
    Color
}

public class NoiseMap : MonoBehaviour
{
    // Исходные данные для нашего генератора шума
    [SerializeField] public int width;
    [SerializeField] public int height;
    [SerializeField] public float scale;

    [SerializeField] public int octaves;
    [SerializeField] public float persistence;
    [SerializeField] public float lacunarity;

    [SerializeField] public int seed;
    [SerializeField] public Vector2 offset;

    [SerializeField] public MapType type = MapType.Noise;

    private void Start()
    {
        GenerateMap();
    }

    public void GenerateMap()
    {
        // Генерируем карту
        float[] noiseMap = NoiseMapGenerator.GenerateNoiseMap(width, height, seed, scale, octaves, persistence, lacunarity, offset);

        // Передаём карту в рендерер
        NoiseMapRenderer mapRenderer = FindObjectOfType<NoiseMapRenderer>();
        mapRenderer.RenderMap(width, height, noiseMap, type);
    }
}
  1. NoiseMapRenderer - создаёт текстуру для отображения карты
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
using System;
using System.Collections.Generic;
using UnityEngine;

public class NoiseMapRenderer : MonoBehaviour
{
    [SerializeField] public SpriteRenderer spriteRenderer = null;

    // Определение раскраски карты в зависимости от высот
    [Serializable]
    public struct TerrainLevel
    {
        public string name;
        public float height;
        public Color color;
    }
    [SerializeField] public List<TerrainLevel> terrainLevel = new List<TerrainLevel>();

    // В зависимости от типа отрисовываем шум либо в чёрно-белом, либо цветном варианте
    public void RenderMap(int width, int height, float[] noiseMap, MapType type)
    {
        if (type == MapType.Noise)
        {
            ApplyColorMap(width, height, GenerateNoiseMap(noiseMap));
        }
        else if (type == MapType.Color)
        {
            ApplyColorMap(width, height, GenerateColorMap(noiseMap));
        }
    }

    // Создание текстуры и спрайта для отображения
    private void ApplyColorMap(int width, int height, Color[] colors)
    {
        Texture2D texture = new Texture2D(width, height);
        texture.wrapMode = TextureWrapMode.Clamp;
        texture.filterMode = FilterMode.Point;
        texture.SetPixels(colors);
        texture.Apply();

        spriteRenderer.sprite = Sprite.Create(texture, new Rect(0.0f, 0.0f, texture.width, texture.height), new Vector2(0.5f, 0.5f), 100.0f);;
    }

    // Преобразуем массив с данными о шуме в массив чёрно-белых цветов, для передачи в текстуру
    private Color[] GenerateNoiseMap(float[] noiseMap)
    {
        Color[] colorMap = new Color[noiseMap.Length];
        for (int i = 0; i < noiseMap.Length; i++)
        {
            colorMap[i] = Color.Lerp(Color.black, Color.white, noiseMap[i]);
        }
        return colorMap;
    }

    // Преобразуем массив с данными о шуме в массив цветов, зависящих от высоты, для передачи в текстуру
    private Color[] GenerateColorMap(float[] noiseMap)
    {
        Color[] colorMap = new Color[noiseMap.Length];
        for (int i = 0; i < noiseMap.Length; i++)
        {
            // Базовый цвет с самым высоким диапазоном значений
            colorMap[i] = terrainLevel[terrainLevel.Count-1].color;
            foreach (var level in terrainLevel)
            {
                // Если шум попадает в более низкий диапазон, то используем его
                if (noiseMap[i] < level.height)
                {
                    colorMap[i] = level.color;
                    break;
                }
            }
        }

        return colorMap;
    }
}
  1. Создайте объект с новыми скриптами и компонентом Sprite Renderer

Game object

  1. Настройте палитру для карты высот

Terrain levels

Результат

На выходе мы получили карту высот, которая похожа на отдельную часть ландшафта.
Result map

Заключение

Мы познакомились с базовыми понятиями процедурной генерации и Шумом Перлина, а также сгенерировали с их помощью карту высот, которая может напоминать реальный ландшафт. Это очень простой алгоритм, который позволяет добиться приемлемых результатов за небольшое время. Самым очевидным способом применения для которой будет наложение на объект типа Plane и создание, таким образом, 3D ландшафта, как и сделал Sebastian в своей серии видео. Однако, это всего лишь базовый механизм, который не учитывает множество параметров при создании ландшафта, как, например, климат. Также независимость расчётов, которая, с одной стороны, позволяет создать бесконечное пространство, с другой, выдаёт довольно однообразный результат и не позволяет создавать карты, учитывающие разделение на континенты или острова, для этого потребуется вводить дополнительные механизмы. Тем не менее в следующих статьях этого цикла мы попробуем применить этот базовый алгоритм для чего-то более интересного, ведь по большей части основным ограничением является лишь наша фантазия, ведь даже Minecraft изначально основывался на Шуме Перлина. Пока! =)

Bonus

Одна из моих самых любимых статей на эту же тему, да и в целом этот блог содержит много интересной информации по разработке игровых механик.



Privacy policyCookie policyTerms of service
Tulenber 2020