Isometric maps generation with Perlin Noise. Part 2

Tulenber 22 May, 2020 ⸱ Intermediate ⸱ 16 min ⸱ 2019.3.14f1 ⸱

That is our second article about isometric maps based on Perlin Noise in the cycle about procedural generation.

The procedural generation of space implies the usage of mechanisms that create its elements independently of each other. In the future, to describe these elements, we will use the term - sector. Last time, we used the previously prepared Perlin Noise generation algorithm to simulate the earth’s surface and convert it into an isometric tilemap. The next steps will be the division of the map into sectors, their separate generation, and subsequent merging into one united object.

Goal

Generate data for the sector with Noise Perlin and combine several sectors into a united map covering the camera view.

Isometric view

Previously we use standard Unity mechanisms for isometric maps. Working with coordinates did not go beyond the usual positioning of tiles one after another and did not require a deep understanding of the process. However, our further actions will need a basic knowledge of isometric space, so let’s take a little look at the theory. As always, I’ll try not to go too deep and consider only the important for the current article elements. For more information, you can always turn to the Internet, as an option, start with Wikipedia.

Isometric projection is a method for visually representing three-dimensional objects in two dimensions with different distortion coefficients along the coordinate axes. Last time, we chose the isometric map where positioning uses the X and Y axes, and the height set with the Z-axis. Accordingly, we would like to note our transition to three-dimensional space and define terms for determining the direction. On the X-axis, we will count the length; on the Y-axis - the width and along the Z-axis - the height. Height is currently not a significant factor for us, so we will omit its presence and focus on the X and Y axes.

The simplest way to representing space is to display it on a plane, for example, the coordinates of tiles in two-dimensional space:
2D grid

By default, the coordinate axes in Unity isometric tilemaps directed upward (relative to the display on the screen), respectively, this is the isometric projection of our space:
Isometric grid

Sector

Sector - is a group of tiles with the same length and width (the height, as we agreed earlier, is omitted). I note right away that sectors behave in space in the same way as individual tiles so that we can ignore what objects we are currently dealing with. Usually, we will operate with sectors.

The sector in two-dimensional space:
2D sector

The sector in isometric space:
Isometric sector

Display isometric space on the screen

I think it is clear that changing the direction of the coordinate axes changes the application of length and width to determine the number of rows and columns needed to fill the entire screen. Direct usage of length and width will either leave the corners of the screen unfilled or will place many of the sectors beyond the boundaries of the visible region.

The most efficient sector allocation looks like that:
Isometric screen

The number of columns can be calculated using the formula int sectorCountX = Mathf.CeilToInt (cameraWidth / (TileMapSector.SectorSizeInTiles * cellSize.x)) + 1; additional sector closes the extreme values.
Accordingly, the formula for the number of rows is int sectorCountY = 2 * (Mathf.CeilToInt (cameraHeight / (TileMapSector.SectorSizeInTiles * cellSize.y)) + 1); where an additional sector also closes the extreme values, and a doubled size is taken due to the shift of the lines relative to each other only half the height.

This formula will not give us perfect placement and will contain an equal number of sectors in each row. Still, now we will step aside resource optimization to reduce code complexity.

Screen filling

The cherry on the cake will be an algorithm for this “effective” sector allocation.

To offset along the rows, we use a sequential increase of the X and Y coordinates. For offset along with the columns, according to the isometric coordinate grid, it is necessary to increase X and to decrease Y by one.
Isometric screen fill

Implementation

The central part of this article is the addition of the sectors, which required a little refactoring of the existed codebase and solving of the following four tasks:

  • Calculation of bias for generating Perlin Noise
  • Calculation of the number of sectors needed to fill the screen
  • Calculation of the position of sectors in space
  • Sector placement for screen-filling
Noise generator refactoring

The noise generator settings move to a separate structure; this will simplify their transfer between different objects.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
using System;
using UnityEngine;

[Serializable]
public struct GeneratorSettings
{
    public float scale;
    
    public int octaves;
    public float persistence;
    public float lacunarity;

    public int seed;
    public Vector2 offset;
}

Accordingly, we add an interface to work with settings from such a structure to the noise generator.

 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
77
78
79
using UnityEngine;

public static class NoiseMapGenerator
{
    public static float[] GenerateNoiseMap(GeneratorSettings settings, Vector2 offset)
    {
        // Offset from settings
        Vector2 generationOffset = offset + settings.offset;
        // The sizes of the generated zone are equal to the size of the sector by default
        return GenerateNoiseMap(TileMapSector.SectorSizeInTiles, TileMapSector.SectorSizeInTiles, settings.seed, settings.scale, settings.octaves, settings.persistence, settings.lacunarity, generationOffset);
    }
    
    public static float[] GenerateNoiseMap(int width, int height, int seed, float scale, int octaves, float persistence, float lacunarity, Vector2 offset)
    {
        // An array of vertex data, a one-dimensional view will help get rid of unnecessary cycles later
        float[] noiseMap = new float[width*height];

        // Seed element
        System.Random rand = new System.Random(seed);

        // Octave shift to get a more interesting picture with overlapping
        Vector2[] octavesOffset = new Vector2[octaves];
        for (int i = 0; i < octaves; i++)
        {
            // Also use external position shift
            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;
        }

        // For a more visually pleasant zoom shift our view to the center
        float halfWidth = width / 2f;
        float halfHeight = height / 2f;

        // Generate points for a heightmap
        for (int y = 0; y < height; y++)
        {
            for (int x = 0; x < width; x++)
            {
                // Set the values for the first octave
                float amplitude = 1;
                float frequency = 1;
                float noiseHeight = 0;
                float superpositionCompensation = 0;

                // Octave Overlay Processing
                for (int i = 0; i < octaves; i++)
                {
                    // Calculate the coordinates to get from Noise Perlin
                    float xResult = (x - halfWidth) / scale * frequency + octavesOffset[i].x * frequency;
                    float yResult = (y - halfHeight) / scale * frequency + octavesOffset[i].y * frequency;

                    // Obtaining Altitude from the PRNG
                    float generatedValue = Mathf.PerlinNoise(xResult, yResult);
                    // Octave overlay
                    noiseHeight += generatedValue * amplitude;
                    // Compensate octave overlay to stay in a range [0,1]
                    noiseHeight -= superpositionCompensation;

                    // Calculation of amplitude, frequency and superposition compensation for the next octave
                    amplitude *= persistence;
                    frequency *= lacunarity;
                    superpositionCompensation = amplitude / 2;
                }

                // Save heightmap point
                // Due to the superposition of octaves, there is a chance of going out of the range [0,1]
                noiseMap[y * width + x] = Mathf.Clamp01(noiseHeight);
            }
        }

        return noiseMap;
    }
}

Refactoring of the test displaying of the generated space

Test rendering is separated from the main class responsible for the generation of isometric maps and repeats the algorithm for generating isometric structures, but for a two-dimensional texture.

  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
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Tilemaps;

public class NoiseMapRenderer : MonoBehaviour
{
    // Structure with the dependence of the pixel color to the height of the map
    [Serializable]
    public struct TerrainLevel
    {
        public float height;
        public Color color;
    }
    [SerializeField] public List<TerrainLevel> terrainLevel = new List<TerrainLevel>();

    // Function for generating a test texture with the parameters specified for the noise generator
    // Used from the editor extension
    public void GenerateMap()
    {
        // Translation of tile settings into the structure of the dependence of pixel color on height
        TileMapHandler handler = FindObjectOfType<TileMapHandler>();
        List<Tile> tileList = handler.tileList;

        terrainLevel = new List<TerrainLevel>();
        float heightOffset = 1.0f / tileList.Count;
        for (int i = 0; i < tileList.Count; i++)
        {
            Color color = tileList[i].sprite.texture.GetPixel(tileList[i].sprite.texture.width / 2, tileList[i].sprite.texture.height / 2);
            TerrainLevel lev = new TerrainLevel();
            lev.color = color;
            lev.height = Mathf.InverseLerp(0, tileList.Count, i) + heightOffset;
            terrainLevel.Add(lev);
        }

        // Render a test display of space for our camera
        Vector2Int sectorSize = handler.GetScreenSizeInSectors();
        RenderMap(sectorSize.x, sectorSize.y, handler.generatorSettings);
    }

    // Create and draw a noise-based texture
    public void RenderMap(int sectorLength, int sectorWidth, GeneratorSettings settings)
    {
        // Delete previously generated textures for editor mode
        for (int i = transform.childCount; i > 0; --i)
            DestroyImmediate(transform.GetChild(0).gameObject);

        // Initial parameters for sectors traversing
        int startX = -sectorLength;
        int startY = 0;
        int currentSectorX = startX;
        int currentSectorY = startY;
        bool increaseX = true;

        // We shift the index by one to optimize the use of the loop with the remainder of the division
        int lastSectorIndex = sectorLength * sectorWidth + 1;
        for (int sectorIndex = 1; sectorIndex < lastSectorIndex; sectorIndex++)
        {
            // Generate the current sector
            GenerateSector(new Vector2Int(currentSectorX, currentSectorY), settings);

            // Move to the next column
            currentSectorX++;
            currentSectorY--;

            // Go to the next line
            if (sectorIndex % sectorLength == 0)
            {
                // Go to the next line, alternating between increasing X and Y
                if (increaseX)
                {
                    currentSectorX = ++startX;
                    currentSectorY = startY;
                    increaseX = false;
                }
                else
                {
                    currentSectorX = startX;
                    currentSectorY = ++startY;
                    increaseX = true;
                }
            }
        }
    }

    private void GenerateSector(Vector2Int sectorCoordinates, GeneratorSettings settings)
    {
        // Calculate offset of the sector for the noise generator
        int sectorSize = TileMapSector.SectorSizeInTiles;
        float sectorOffsetX = sectorSize * sectorCoordinates.x * sectorSize / settings.scale;
        float sectorOffsetY = sectorSize * sectorCoordinates.y * sectorSize / settings.scale;
        Vector2 sectorOffset = new Vector2(sectorOffsetX, sectorOffsetY);
        // Noise generation
        float[] noiseMap = NoiseMapGenerator.GenerateNoiseMap(settings, sectorOffset);

        // Generate and position the sector texture
        GameObject sectorSprite = new GameObject();

        SpriteRenderer spriteRenderer = sectorSprite.AddComponent<SpriteRenderer>();
        Texture2D texture = new Texture2D(sectorSize, sectorSize)
        {
            wrapMode = TextureWrapMode.Clamp, filterMode = FilterMode.Point
        };
        texture.SetPixels(GenerateColorMap(noiseMap));
        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);
        
        sectorSprite.transform.SetParent(transform, false);
        sectorSprite.name = "" + sectorCoordinates.x + "_" + sectorCoordinates.y;
        float positionOffsetMultiplier = sectorSize * transform.localScale.x / spriteRenderer.sprite.pixelsPerUnit;
        sectorSprite.transform.Translate(new Vector3(sectorCoordinates.x * positionOffsetMultiplier, sectorCoordinates.y * positionOffsetMultiplier), Space.Self);
    }

    // Convert noise to color to render the texture
    private Color[] GenerateColorMap(float[] noiseMap)
    {
        Color[] colorMap = new Color[noiseMap.Length];
        for (int i = 0; i < noiseMap.Length; i++)
        {
            // Basic is the color with the highest level
            colorMap[i] = terrainLevel[terrainLevel.Count-1].color;
            foreach (var level in terrainLevel)
            {
                // Choose a color depending on the noise level
                if (noiseMap[i] < level.height)
                {
                    colorMap[i] = level.color;
                    break;
                }
            }
        }

        return colorMap;
    }
}

Sector

Responsible for the location of tiles on the map.

 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
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Tilemaps;

public static class TileMapSector
{
    public static int SectorSizeInTiles = 10;

    public static void GenerateSector(float[] noiseMap, List<Tile> tileList, Tilemap tileMap)
    {
        // Sizes of the sector
        int length = SectorSizeInTiles;
        int width = SectorSizeInTiles;

        // Clear previous values
        tileMap.ClearAllTiles();

        // Bypass all sector tiles
        for (int y = 0; y < length; y++)
        {
            for (int x = 0; x < width; x++)
            {
                // Noise level for the current tile
                float noiseHeight = noiseMap[length*y + x];

                // The levels of the generated surface are evenly distributed on the noise scale
                // "Stretch" the noise scale to the size of the array of tiles
                float colorHeight = noiseHeight * tileList.Count;
                // Select a tile below the resulting value
                int colorIndex = Mathf.FloorToInt(colorHeight);
                // We consider addressing in arrays for maximum noise values
                if (colorIndex == tileList.Count)
                {
                    colorIndex = tileList.Count-1;
                }

                // Tile assets allow you to use a height of 2z
                // Therefore, we "stretch" the noise scale more than two times with color
                float tileHeight = noiseHeight * tileList.Count * 2;
                int tileHeightIndex = Mathf.FloorToInt(tileHeight);
                
                // We shift the obtained height to align the tiles with water and the first level of sand
                tileHeightIndex -= 4;
                if (tileHeightIndex < 0)
                {
                    tileHeightIndex = 0;
                }

                // Take an asset tile depending on the converted noise level
                Tile tile = tileList[colorIndex];

                // Set the tile height depending on the converted noise level
                Vector3Int p = new Vector3Int(x - length / 2, y - width / 2, tileHeightIndex);
                tileMap.SetTile(p, tile);
            }
        }
    }
}

Calculation of bias for generating Perlin Noise

In our implementation of the generator, accounting for coordinate offsets to obtain noise looks like this:

 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
// Octave shift to get a more interesting picture with overlapping
Vector2[] octavesOffset = new Vector2[octaves];
for (int i = 0; i < octaves; i++)
{
    // Also use external position shift
    float xOffset = rand.Next(-100000, 100000) + offset.x;
    float yOffset = rand.Next(-100000, 100000) + offset.y;
    octavesOffset[i] = new Vector2(xOffset / width, yOffset / height);
}

// For a more visually pleasant zoom shift our view to the center
float halfWidth = width / 2f;
float halfHeight = height / 2f;

// Generate points for a heightmap
for (int y = 0; y < height; y++)
{
    for (int x = 0; x < width; x++)
    {
        // Octave Overlay Processing
        for (int i = 0; i < octaves; i++)
        {
            // Calculate the coordinates to get from Perlin Noise
            float xResult = (x - halfWidth) / scale * frequency + octavesOffset[i].x * frequency;
            float yResult = (y - halfHeight) / scale * frequency + octavesOffset[i].y * frequency;
        }
    }
}

Accordingly, for the correct calculation of the displacement of sectors, the following formula is used:

1
2
3
int sectorSize = TileMapSector.SectorSizeInTiles;
float sectorOffsetX = sectorSize * sector.x * sectorSize / generatorSettings.scale;
float sectorOffsetY = sectorSize * sector.y * sectorSize / generatorSettings.scale;

Calculation of the number of sectors required for tiling the screen

As mentioned above:

1
2
int sectorCountX = Mathf.CeilToInt(cameraWidth / (TileMapSector.SectorSizeInTiles * cellSize.x)) + 1;
int sectorCountY = 2 * (Mathf.CeilToInt(cameraHeight / (TileMapSector.SectorSizeInTiles * cellSize.y)) + 1);

Calculation of the position of sectors in space

It takes into account the displacement of the tiles relative to each other by half the size, as well as the projection of the coordinate axes.

1
2
3
4
5
var tileSize = grid.cellSize;
float positionOffsetMultiplierX = TileMapSector.SectorSizeInTiles * tileSize.x * 0.5f;
float positionOffsetMultiplierY = TileMapSector.SectorSizeInTiles * tileSize.y * 0.5f;
float positionX = currentSectorX * positionOffsetMultiplierX - currentSectorY * positionOffsetMultiplierX;
float positionY = currentSectorX * positionOffsetMultiplierY + currentSectorY * positionOffsetMultiplierY;

Full code of map generation class
  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
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Tilemaps;

public class TileMapHandler : MonoBehaviour
{
    // Prefab of the tilemap
    public GameObject tileMapPrefab = null;
    // Grid for displaying generated tilemaps
    public Grid grid = null;
    // List of tiles
    public List<Tile> tileList = new List<Tile>();

    // Generator settings
    [SerializeField] public GeneratorSettings generatorSettings;

    // List of generated maps
    private readonly List<Transform> _tileMaps = new List<Transform>();

    // Start is called before the first frame update
    void Start()
    {
        // Hide the test texture
        NoiseMapRenderer mapRenderer = FindObjectOfType<NoiseMapRenderer>();
        mapRenderer.gameObject.SetActive(false);

        // Screen size in sectors
        Vector2Int screenSizeInSectors = GetScreenSizeInSectors();

        // Parameters for positioning sectors in space
        var tileSize = grid.cellSize;
        float positionOffsetMultiplierX = TileMapSector.SectorSizeInTiles * tileSize.x * 0.5f;
        float positionOffsetMultiplierY = TileMapSector.SectorSizeInTiles * tileSize.y * 0.5f;

        // Parameters for traversing tiles by row columns
        int startX = -screenSizeInSectors.x;
        int startY = 0;
        int currentSectorX = startX;
        int currentSectorY = startY;
        bool increaseX = true;

        // We shift the index by one to optimize the use of the loop with the remainder of the division
        int lastSectorIndex = screenSizeInSectors.x * screenSizeInSectors.y + 1;
        for (int currentSectorIndex = 1; currentSectorIndex < lastSectorIndex; currentSectorIndex++)
        {
            // Sector object
            Transform sector = GetTileMapSector(new Vector2Int(currentSectorX, currentSectorY));
            sector.SetParent(grid.transform);

            // Calculate the position of the sector in space
            float positionX = currentSectorX * positionOffsetMultiplierX - currentSectorY * positionOffsetMultiplierX;
            float positionY = currentSectorX * positionOffsetMultiplierY + currentSectorY * positionOffsetMultiplierY;
            sector.Translate(new Vector3(positionX, positionY, 0), Space.Self);
            sector.name = "" + currentSectorX + "," + currentSectorY;

            // Save the sector
            _tileMaps.Add(sector);

            // Move to the next column
            currentSectorX++;
            currentSectorY--;

            // Go to the next line
            if (currentSectorIndex % screenSizeInSectors.x == 0)
            {
                if (increaseX)
                {
                    currentSectorX = ++startX;
                    currentSectorY = startY;
                    increaseX = false;
                }
                else
                {
                    currentSectorX = startX;
                    currentSectorY = ++startY;
                    increaseX = true;
                }
            }
        }
    }

    // Screen size in sectors
    public Vector2Int GetScreenSizeInSectors()
    {
        // Camera height
        float cameraHeight = Camera.main.orthographicSize * 2;
        // Camera width equals camera height * aspect ratio
        float screenAspect = Camera.main.aspect;
        float cameraWidth = cameraHeight * screenAspect;

        // Calculation of the number of tiles for tiling the screen
        var cellSize = grid.cellSize;
        int sectorCountX = Mathf.CeilToInt(cameraWidth / (TileMapSector.SectorSizeInTiles * cellSize.x)) + 1;
        int sectorCountY = 2 * (Mathf.CeilToInt(cameraHeight / (TileMapSector.SectorSizeInTiles * cellSize.y)) + 1);
        
        return new Vector2Int(sectorCountX, sectorCountY);
    }

    // Sector generation
    private Transform GetTileMapSector(Vector2Int sector)
    {
        // Tile map object
        GameObject sectorGameObject = Instantiate(tileMapPrefab);

        // Calculate the sector offset for the noise generator
        int sectorSize = TileMapSector.SectorSizeInTiles;
        float sectorOffsetX = sectorSize * sector.x * sectorSize / generatorSettings.scale;
        float sectorOffsetY = sectorSize * sector.y * sectorSize / generatorSettings.scale;
        Vector2 sectorOffset = new Vector2(sectorOffsetX, sectorOffsetY);

        Tilemap sectorTileMap = sectorGameObject.GetComponent<Tilemap>();
        // Tile map sector generation
        TileMapSector.GenerateSector(NoiseMapGenerator.GenerateNoiseMap(generatorSettings, sectorOffset), tileList, sectorTileMap);
        
        return sectorGameObject.transform;
    }
}

Result

Test texture:
Test texture

The result of generating an isometric map divided into sectors:
Result

Conclusion

The addition of sectors is a small step for our example of procedural generation. Still, it solved such critical tasks as positioning the generation area of Perlin Noise, underlying mechanisms for working with an isometric coordinate system and dividing space into sectors. These three tasks are preparation for dynamically sector loading with movement and actual creation of infinite space. But we will deal with this in the next article of our series. See you next time! =)



Privacy policyCookie policyTerms of service
Tulenber 2020