Sunday, November 27, 2022

A Seamless and Wrapping Tilemap Without Chunking

Back when I was working on this game in UNITY, I had developed a chunk engine for my tilemap data, and I was quite happy with it.  I generated map chunks that were stored in files and loaded them into memory as the player moved around the world - the typical map chunk approach.  It had its good points and not-so-good ones.  The game world could be massive because it only loaded chunks of map data.  But, the world did have edges and if the player reached the edge of the world, he would need to alter direction.  Also, there was always a noticeable lag spike when the engine decided it was time to load and unload chunk data.  Then too, there were so many tiles in memory at any given moment.  The chunks were 25x25 if I remember correctly, and there were 9 chunks possibly in memory.  That amounts to upwards of 5625 tiles.

When I switched to Godot, I wanted to do something different.  I didn't want the player to ever see the edge of the tilemap world.  Also, I didn't want to deal with chunk loading and lag spikes.  Finally, I wanted the map to be seamless so the tiles would blend at the edges.

A Wrapping Technique

In order to visualize tilemap wrapping, I did what software engineers do.  I threw together a program to simulate my goal.  I opened up Visual Studio and created a Winforms application.  I generated a grid of random numbers to represent tiles.  I then wrote an algorithm to determine which numbers needed to be populated based on the center target number, the player.  While the code turned out to be fairly straightforward, taking this approach allowed me to visually verify my code was working as intended.

The above example seems easy enough to figure out.  But the code needed to figure out which tiles would surround the player no matter where the player was on the map.  Consider the following example.

Since the player is on the east edge of the map, the western edge needed to be included in the surrounding tiles of the player.  Let's get a little more complicated and put the player on two edges of the map.

The above example needs to pull from all four corners of the map because the player "4" is against both the east and south edges.  The code in the WinForms app successfully pulled together all the numbers to display the correct area around the player and assured me that it worked.  Here is my Winforms C# code for populating the numbers on the right side of the screenshots, which represents the view of the map in the game viewport.

private void btnLocate_Click(object sender, EventArgs e)
        {
            tbView.Clear();
            var playerX = numX.Value;
            var playerY = numY.Value;            
            int start_X = decimal.ToInt32(playerX - (decimal)Math.Floor(viewSize * .5));
            int start_Y = decimal.ToInt32(playerY - (decimal)Math.Floor(viewSize * .5));
            for (int y = 0; y < viewSize; y++)
            {
                for (int x = 0; x < viewSize; x++)
                {
                    int pos_x = start_X + x;
                    int pos_y = start_Y + y;
                    int key_x;
                    if (pos_x >= mapSize)
                        key_x = pos_x - mapSize;                    
                    else if (pos_x < 0)
                        key_x = mapSize - Math.Abs(pos_x);
                    else key_x = pos_x;

                    int key_y;
                    if (pos_y >= mapSize)
                        key_y = pos_y - mapSize;
                    else if (pos_y < 0)
                        key_y = mapSize - Math.Abs(pos_y);
                    else key_y = pos_y;

                    tbView.AppendText(" " + mapData[key_y, key_x].ToString() + " ");
                    if (x == viewSize - 1)
                        tbView.AppendText("\r\n");
                }
            }
        }

The "key" is the key!  If position steps outside of the map size, the key compensates by pulling tile data from the opposite side of the map.  How did I implement this?  Well, first I need to explain my concept of a world without chunks.  When I generate my perlin noise tilemap, I store the tilemap data in a dictionary where the key to the dictionary is a Vector2 map position and the value is a tilemap tile type.  The dictionary is held in a singleton and gets saved and loaded along with all the rest of the game's save data.  I also store the current tile position of the player in this singleton.  The tilemap never gets loaded with the entire dictionary of tile data.  It only gets loaded with those tiles around the player's map position, the viewport plus a few tiles' worth of border.  Every time the player moves, I update the player position and then I clear the tilemap and re-populate the tilemap with the new tile content around the player.  This means every move of the player will clear the tilemap, load it with the new tiles, and render them to the viewport.  The code above handles the wrapping of the map so that I know which tiles to read from the dictionary.  The effect is that the player can move around the game world, no matter how large the dictionary world is, without chunking and without lag.  I have an established view size of 49x33 (which is very close to 16:9 ratio).  This configuration loads 1617 tiles and renders them in the tilemap at the end of every player move.  Does it studder?  Not even remotely!  There is zero lag, zero flicker, and the player never sees the edge of the tilemap.  Not only that, but my tilemap tile types are all AUTO_TILE types.  It is simply beautiful.  The map data for the entire map is in memory, not as tile nodes, but simply as dictionary entries of a tile type indexed by a Vector2, which is quite small in comparison.  This makes the tilemaps only ever 49x33 in size even though the dictionary may hold a million tile entries or more!

Seamless Perlin Noise

The wrapping tilemap would be of little benefit unless I was able to create seamless Perlin noise.  It took me quite a while of digging and searching to figure out how to do this using Godot's get_seamless_image() feature.  It is not well documented.  Fortunately, I found an implementation of it and it works well.  This is the start of my generate_world_data function().  The following code is GDScript:

    randomize()   
    var noise = OpenSimplexNoise.new()
    noise.seed = randi()
    noise.octaves = 2
    noise.period = 192
    noise.persistence = .75
    noise.lacunarity = 4
    var noise_image = noise.get_seamless_image(auto_global.map_size.x)   
    noise_image.lock()
    for x in auto_global.map_size.x:
        for y in auto_global.map_size.y:                       
            var pixel_map = noise_image.get_pixel(x,y)
            var noise_value = pixel_map[0]    #0 to 1           
            var map_tile_cord = Vector2(x,y)

Auto_global is a singleton where I store the size of the game world and the tilemap dictionary data.  The noise_value uses the first value from the pixel data.  This value ranges from 0 to 1, unlike noise.get_noise_2d(x, y) which contains values ranging from -1 to 1.  Taking this along with the logic from the code above, it is very easy to take a flat 2d tilemap world and turn it into a circular ball of sorts.  The result is a seamless, wrapping tilemap with no chunking required.

Feel free to leave comments or ask questions.  Thanks for visiting and reading along on my game-dev journey.

Until next time...

No comments:

Post a Comment

Feel free to leave a comment. All comments are moderated.