Saturday, July 10, 2021

Eliminating Visible Chunk Edge Lines The Easy Way!

NOTE:  This post and all before it were made during the time when the game was being developed in the UNITY game engine.  The game is no longer being developed in UNITY.  New posts will detail my journey using the Godot game engine.  If you are interested in UNITY, please browse my posts where you might find some very interesting UNITY content.

In my last post, I admitted my chunk engine had another issue I needed to deal with.  The issue had to do with the edge tiles of each chunk not being able to select the appropriate matching edge tile from an adjacent chunk.  This resulted in chunk edge lines that would appear on the map.  Here is an example screenshot.

Chunk Engine Fail!

When I sat down today to tackle this problem, I was really apprehensive about how long it would take to address this issue.  My initial thought was to manipulate the chunk sizes to store one extra tile around the chunk so the chunk would know what the adjacent tile was, thus being able to select the appropriate tile sprite.  I Googled and read others had that same line of thinking.  And as I sat looking at the above screen and those ugly chunk lines, it happened!  I had one of those Aha! moments that developers sometimes have.  I remembered how my chunk engine was loading the chunks in a set sequence.  It was like this:

The Flawed Chunk Loading Sequence

C0 is the chunk the player is on.  It is the most important chunk to load, right?  So, I loaded it first!  But that was where I made my mistake.  The reason the chunk edge lines were appearing was due to the fact that I had chosen to load the player's chunk FIRST.  In doing that, I guaranteed the edge tiles would not be right because they had nothing to reference!  So, I decided to change my switch statement around in my chunk loading method from the above order 0-8 to this new 0-8 order.

The New and Improved Chunk Loading Sequence

Well, you can just imagine what that did.  Actually, you do not have to imagine because I am going to now show you.
Chunk Engine Success!

By loading the edge chunks first, the center chunk - the most critical - had all the information it needed to eliminate the visible lines.  This simple act of changing the load order essentially pushed the edge lines to the outside of the 9-chunk group rather than the inside.  Since chunks load as the player moves around, the player actually never does see the chunk lines because they never appear in the game window.

You might be wondering what the player sees when those chunks around his character are loading?  Is the character suspended above a black hole of empty tiles while the chunks around him are forming?  No, he isn't.  That's because the player never sees any of this loading take place.  Earlier today, as if it were perfect timing, I wrote a fade-out, fade-in coroutine to hide the game world while it was being formed.  Here is the method.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public IEnumerator FadeScene(float fadeOutSpeed = 25f, float fadeInSpeed = 3f, float waitTime = 1.5f)
        {            
            Color fadeColor = BlackOut.GetComponentInChildren<Image>().color;
            float fadeAmount;
            while (fadeColor.a < 1f)
            {
                fadeAmount = fadeColor.a + (fadeOutSpeed * Time.deltaTime);
                if (fadeAmount > 1)
                    fadeAmount = 1;
                fadeColor = new Color(fadeColor.r, fadeColor.g, fadeColor.b, fadeAmount);
                BlackOut.GetComponentInChildren<Image>().color = fadeColor;
                yield return null;
            }
            yield return new WaitForSeconds(waitTime);
            while (fadeColor.a > 0f)
            {
                fadeAmount = fadeColor.a - (fadeInSpeed * Time.deltaTime);
                if (fadeAmount < 0)
                    fadeAmount = 0;
                fadeColor = new Color(fadeColor.r, fadeColor.g, fadeColor.b, fadeAmount);
                BlackOut.GetComponentInChildren<Image>().color = fadeColor;
                yield return null;
            }
        }   

The method is meant to be called as a Coroutine like this:

StartCoroutine(FadeScene(15f, 1f, 2f));

If you leave off supplying the parameters, the defaults will be used.  The first parameter is the fadeout speed.  For example, 25f will perform a complete fade to black in 4 frames.  The second parameter is the fadein speed.  The last parameter is a wait timer you can use to delay the fadein if you need to allow it to pause longer for longer background work.

Having this method alone will do nothing.  In order to fade the screen, you must have something to adjust the alpha on.  You need a sprite on a canvas object that is expanded to take up the whole screen.  Here is how to do that.

On your canvas object in your scene hierarchy, create a new child canvas and name it BlackOut (or whatever you like).  Make sure it is at the bottom of the list of canvases you have, which will make it the closest to the camera.  Next, set the Rect Transform to stretch in both directions.

Add a child canvas to the primary canvas in your scene.


Then, add an image as a child of the BlackOut canvas.  Set the image color to black and (this is important) set the alpha to 0 so it is initially transparent.  Be sure to also stretch this Image transform in both directions as you did with the canvas.  

Add an Image to the child canvas and set properties.

Inside the script where you will keep the FadeScene method, create a Public GameObject BlackOut, and then drag the BlackOut canvas object from the Hierarchy to the public property you just exposed.  This will allow the method to find the BlackOut Image.  That's really it.  Once you have this set up, you can use this FadeScene any time you need to fade the scene to do some work without the player seeing what is going on.  It will do both the fade in and fade out in one coroutine.  You don't have to call it again to stop it.  Reuse it anywhere you like.

Going back to the chunk line solution, what I thought would be a huge headache to contend with turned out to be a simple step-back-and-rethink approach that required no new code.  I reassigned the chunks to their new numbered sequence and that was it.  I could beat myself up for not thinking of it at first.  But then, I would not have had this awesome Aha! moment that we developers find so exhilarating!

Until next time....

Sunday, July 4, 2021

Revisiting the Chunking Engine

Last August, I wrote a post about my tilemap chunking engine.  The initial design involved loading 9 chunks of map data around the player, C0 being the chunk the player is on at any given moment.

9 Chunks Around Player

As the player moves around the map, loaded chunks that fall outside this list of 9 need to be saved and unloaded.  Back then I was concerned about figuring out how to unload the adjacent chunks beyond this list of 9 around the player.  Here was my thinking and approach.
"As the player moves around, there is a constant check against the player position and the id of the chunk that is two chunks away from the player's position.  Those need to be saved, then unloaded.  Currently, the check is very precise at exactly 2 chunk sizes away from the player."
The implementation required code that calculated up to 21 chunks at a time.  After coding this approach, I recall how frustrated I was that chunks would get missed when I dragged the player around the map from within Unity's Scene view at runtime.

Recently, it dawned on me that my original design had a very serious flaw.  Any player teleportation code I might add in the future would render my chunk unloading method totally useless.  In that use-case, every chunk outside of the player's new 9 chunks would need to be unloaded, including the previous 9 chunks the player was whisked away from.  It was at that moment I realized I was looking way to narrowly at the problem and the solution was embarrassingly simple.  That is what I wish to share with you in this post.

The chunk engine needs to manage three lists of chunk ids and perform three actions against those lists.  In my engine, a chunk is defined as a 25x25 tile section of the tilemap.  When I create the Perlin-based tilemaps, I store each chunk's tile data in a JSON file, using the filename to indicate the chunkid and tilemap it belongs to (I have multiple tilemaps).  

The first list to manage is the list of chunks that are loaded into memory.  This list must always include the current 9 chunks around the player.  My first method is named CheckChunksToLoad(int direction) in which I pass a direction value in each Update cycle.  I start with 0, the player's chunk, and rotate clockwise from C0 to C8 as shown above.  Using a switch statement, each cycle is responsible for one thing, checking to make sure the chunk at the direction is loaded in memory.  When my direction = 9, it is reset to 0, and the cycle repeats.

The second list to manage is the list of currently loaded chunks EXCEPT the 9 around the player's current position.  For this, I have a second method named CheckChunksToUnload() which creates a new List<int> chunkIdListToKeep.  It builds the current list of 9 chunks that must remain in memory.  Next,  it updates the third list to manage, which is the chunks that must be unloaded.  Elegantly, one line of code updates this list once I know which chunks to keep.

_chunksToUnloadList.AddRange(_loadedChunkIdList.Except(chunkIdListToKeep).ToList());

The third method is responsible for unloading a single chunk from the _chunksToUnloadList, and it does so with each Update().  It is aptly named UnloadAChunk().  This method, which is 15 lines of code, is so much simpler than trying to calculate 21 chunk ids.  If the _chunksToUnloadList.Count > 0, it means a chunk needs to be unloaded.  So, it grabs the first chunkId from the list and calls the method which saves the changes to that chunk and updates the JSON file.  That method also clears the chunk from the tilemaps and frees up memory.  Once this is done, the method ends with two very important lines which removes the chunkId from both managed lists.

_loadedChunkIdList.RemoveAll(i => i == chunkId);

_chunksToUnloadList.RemoveAll(i => i == chunkId);

There are several advantages to the new approach.  I only ever need to calculate the chunkIds of the 9 chunks around the player.  Every other chunk on the map gets cleaned up.  The engine runs faster and provides chunk cleanup regardless of how I manage the player's position.  It just works.

I will leave you with some calculations you might find useful if you are working with a tilemap and need to identify chunks to unload.  

int chunkId = _chunksToUnloadList.First();

int maxChunksWide = _ms / _cs;

int yVal = chunkId / maxChunksWide;

int unloadChunkAtY = (_cs * yVal);

int xVal = chunkId - (yVal * maxChunksWide);

int unloadChunkAtX = xVal * _cs;

The above calculations allow you to determine the 0-based x (horizontal) and y (vertical) coordinates of the first tile in each chunk of the tilemap where the first tile is the bottom-left tile in the chunk and the chunks start in the bottom-left corner of the tilemap.  The _ms variable is the width of the tilemap assuming the tilemap is square.  The _cs variable is the width of a chunk in number of tiles.  As an example, if you have a tilemap that is 125x125 tiles, with each chunk being 25x25, you will have the following sample data.

chunk 0:  y=0, x=0
chunk 1:  y=0, x=25
chunk 2:  y=0, x=50
chunk 3:  y=0, x=75
chunk 4: y=0, x=100
chunk 5: y=25, x=0
...
chunk 24: y=100, x=100

For the curious, I do not use the tile palette or rule-tile system in Unity.  I coded my own rule-tile system which essentially does the same thing (don't ask why).  This means my chunk engine does not (yet) have the ability to handle chunk edge-tile rules which would blend the tiles properly at the chunk edges.  This is something I am aware of and will address in the future.  Likely, the solution will involve loading an extra tile around each chunk so the engine knows what tile exists beyond the edge of the chunk.  But that is another backlog item for the future.  

I hope you found this post interesting.  Please feel free to leave comments.  Thanks for reading along.

Until next time...