AlexStv

Unity voxel block tutorial pt. 5

Now we'll take a look at saving and loading block data for creating a persistent world and look at some ways to optimise our save files. To save our blocks we'll serialize them as binary and then de serialize them to load them back into the game.

We'll keep all the serialization stuff in a class so create a new script called Serialization and make it a static class with no inheritance. In addition you'll need to include some references that we'll be needing for the serialization so make sure your new class is set up like this:

using UnityEngine;
using System.Collections;
using System.IO;
using System;
using System.Runtime.Serialization.Formatters.Binary;
using System.Runtime.Serialization;
public static class Serialization
{
}

What we'll do is create a save file for every chunk that can be loaded individually that is saved to a folder in the game's directory. First we'll create two functions that decide where to save our chunk. Let's create the function SaveLocation that will give use the save directory. Before we can do that though we're going to need the world class to have a name so that we can tell our saves apart when we have more that one world. First you'll need to open your World script and add a new variable for the name of the world:

     public string worldName = "world";

This will give us the name of the world to identify the saves, now switch back to our new Serialize script and we can add a variable for the save folder name:

     public static string saveFolderName = "voxelGameSaves";

And then add the function:

     public static string SaveLocation(string worldName)
     {
         string saveLocation = saveFolderName + "/" + worldName + "/";
 
         if (!Directory.Exists(saveLocation))
         {
             Directory.CreateDirectory(saveLocation);
         }
 
         return saveLocation;
     }

This location will be placed within your game's directory in a folder named by saveFolderName with a folder for every world.

Now we need the name of the actual file we're going to save to so we'll add another function to make a string file name from the chunk's location:

     public static string FileName(WorldPos chunkLocation)
     {
         string fileName = chunkLocation.x + "," + chunkLocation.y+ "," + chunkLocation.z +".bin";
         return fileName;
     }

We just need to take the position, separate the components with commas and add .bin as the extension because its going to be a binary file.

Now we'll make a few changes to other scripts before we continue, open the Chunk script and change the block array from private to public:

//    private Block[, ,] blocks = new Block[chunkSize, chunkSize, chunkSize];
      public Block[, ,] blocks = new Block[chunkSize, chunkSize, chunkSize];

Next we can make the save function which will use these to decide the file location:

     public static void SaveChunk(Chunk chunk)
     {
         string saveFile = SaveLocation(chunk.world.worldName);
         saveFile += FileName(chunk.pos);
 
         IFormatter formatter = new BinaryFormatter();
         Stream stream = new FileStream(saveFile, FileMode.Create, FileAccess.Write, FileShare.None);
         formatter.Serialize(stream, chunk.blocks);
         stream.Close();
 
     }

Firstly we use the functions we made earlier to create a file path for our new file. Then we create a BinaryFormatter and a Filestream with the file location and create and write access. Then we use formatter.Serialize with the stream and the chunk's block array. Serializing array is about the simplest thing we can do but later on we can look at including some more information with the save and at what we could do to save less. Lastly close the file stream. Now we can save any chunk at any time with Serialization.Save(Chunk). But that's useless without a load function so we'll add another function:

     public static bool Load(Chunk chunk)
     {
         string saveFile = SaveLocation(chunk.world.worldName);
         saveFile += FileName(chunk.pos);
 
         if (!File.Exists(saveFile))
             return false;
 
         IFormatter formatter = new BinaryFormatter();
         FileStream stream = new FileStream(saveFile, FileMode.Open);
 
         chunk.blocks = (Block[,,]) formatter.Deserialize(stream);
         stream.Close();
         return true;
     }

What this does is populate a chunk with the save if there is one. So we get a file location, if it doesn't exist we return false so the function that called it knows that there was nothing to load. Then using another binary formatter and file stream we can de serialize the file as a Block array and set the chunk's block array to that. Then we close the stream and return true.

Now to make the blocks themselves serializable we'll need to mark them as such, we'll start with the Block script so open Block.cs and we'll add two lines:

 using UnityEngine;
 using System.Collections;
 using System;       //Add this line
 
 [Serializable]      //And this line
 public class Block
 {
     ...

The [Serializable] tag lets the class be saved as binary including all the private variables and it requires System to work so we need to include that dependency. You will need to add this to every block class so that they can all be saved. If you miss any it will raise an exception while attempting to save.

Now we can save and load any chunk we want! Next we'll try and use these functions. Open the World script and we'll add saving and loading when creating and destroying chunks. Starting with the destroy chunk function:

     public void DestroyChunk(int x, int y, int z)
     {
         Chunk chunk = null;
         if (chunks.TryGetValue(new WorldPos(x, y, z), out chunk))
         {
             Serialization.SaveChunk(chunk);    //Add this line to the function
             UnityEngine.Object.Destroy(chunk.gameObject);
             chunks.Remove(new WorldPos(x, y, z));
         }
     }

That will ensure that every time you destroy a block it's saved. Now we can also make it load the saved chunk when we create a new chunk, we'll need to try and load the chunk but if it fails it has to load the default chunk.

     public void CreateChunk(int x, int y, int z)
     {
         //the coordinates of this chunk in the world
         WorldPos worldPos = new WorldPos(x, y, z);
 
         //Instantiate the chunk at the coordinates using the chunk prefab
         GameObject newChunkObject = Instantiate(
                         chunkPrefab, new Vector3(worldPos.x, worldPos.y, worldPos.z),
                         Quaternion.Euler(Vector3.zero)
                     ) as GameObject;
 
         //Get the object's chunk component
         Chunk newChunk = newChunkObject.GetComponent<Chunk>();
 
         //Assign its values
         newChunk.pos = worldPos;
         newChunk.world = this;
 
         //Add it to the chunks dictionary with the position as the key
         chunks.Add(worldPos, newChunk);
 
         //Add these three lines:
         bool loaded = Serialization.Load(newChunk);
         if (loaded)
             return;
         for (int xi = 0; xi < 16; xi++)
         {
             for (int yi = 0; yi < 16; yi++)
             {
                 for (int zi = 0; zi < 16; zi++)
                 {
                     if (yi <= 7)
                     {
                         SetBlock(x + xi, y + yi, z + zi, new BlockGrass());
                     }
                     else
                     {
                         SetBlock(x + xi, y + yi, z + zi, new BlockAir());
                     }
                 }
             }
         }
     }

Add the lines 23, 24 and 25. This will try to load the chunk and set the return value to the boolean loaded. Then we can check it and if we loaded successfully return otherwise continue and generate the chunk.

We also need a way to test this so we'll make a function for destroying and creating chunks so add the following variables to the world script to specify the chunk position and a boolean to trigger the function:

     public int newChunkX;
     public int newChunkY;
     public int newChunkZ;
 
     public bool genChunk;

Then add add this to the update function:

     // Update is called once per frame
     void Update () {
         if (genChunk)
         {
             genChunk = false;
             WorldPos chunkPos = new WorldPos(newChunkX, newChunkY, newChunkZ);
             Chunk chunk = null;
 
             if (chunks.TryGetValue(chunkPos, out chunk))
             {
                 DestroyChunk(chunkPos.x, chunkPos.y, chunkPos.z);
             }
             else
             {
                 CreateChunk(chunkPos.x, chunkPos.y, chunkPos.z);
             }
         }
     }

This will make it so that when you enable the bool genChunk it will check for a chunk in that position and if found save and destroy it and if not found it will create a new chunk there. Try it out in game by making some changes to a chunk, then destroy and re create it. Your changes should still be there. The chunk will be saved to a folder in your game directory.

You should now be able to start the game, make changes to a block, destroy the chunk containing it using the inspector (put the chunk's coordinates in the inspector variables then set genChunk to true) and then create the chunk again by pressing gen chunk with the same coordinates or start and stop the game and your changes should persist.

Optimization

This is the most basic approach to saving but the biggest problem with it is the file size, each chunk will create a 57kb save file and every chunk is saved. This means that a player travelling a long distance will save every block of every chunk loaded. We would rather just save the blocks the player changes and not even save chunks the player hasn't changed.

We'll start by adding a boolean to each block indicating whether the block is new so add this variable to Block.cs:

     public bool changed = true;

It will be true by default so that every block is considered a player change to be saved unless told otherwise. We will need a way to mark all the blocks we add for new block's, the non-player added blocks. So open the chunk.cs script and add the following function:

     public void SetBlocksUnmodified()
     {
         foreach (Block block in blocks)
         {
             block.changed = false;
         }
     }

We won't use this function just yet, we will later on use it in the create chunk script to mark all the automatic terrain as unchanged but first we'll be creating a new class for our save files. This new class will contain everything we want to serialize so we can stop using an array. Create a new script called Save and set it up like this:

using UnityEngine;
using System.Collections;
using System.Collections.Generic;
using System;
[Serializable]
public class Save {
}

It's serializable so we can use it to save. In this case we'll use a dictionary to store the blocks we're saving but there are a lot of structures to store save data you can look into. We'll use the WorldPos struct we set up earlier as the key with the block as the value so the WorldPos struct needs to be serializable as well so add the Using system and [Serializable] tags to the WorldPos file as well:

//WorldPos.cs
using UnityEngine;
using System.Collections;
using System;
[Serializable]
public struct WorldPos {
...

And now switch back the the Save script and add the dictionary variable:

     public Dictionary<WorldPos, Block> blocks = new Dictionary<WorldPos,Block>();

And next we'll need a way to get all the blocks from the chunk into our dictionary so add a function to do that:

 public Save(Chunk chunk)
     {
         for (int x = 0; x < Chunk.chunkSize; x++)
         {
             for (int y = 0; y < Chunk.chunkSize; y++)
             {
                 for (int z = 0; z < Chunk.chunkSize; z++)
                 {
                     if (!chunk.blocks[x, y, z].changed)
                         continue;
 
                     WorldPos pos = new WorldPos(x,y,z);
                     blocks.Add(pos, chunk.blocks[x, y, z]);
                 }
             }
         }
     }

We look through every block in the chunk and if the changed boolean is true we add it to the dictionary using the position as the key. No we'll implement this in the serialization class so open Serialization.cs and first we'll make these changes to the save function:

     public static void SaveChunk(Chunk chunk)
     {
         Save save = new Save(chunk);    //Add these lines
         if (save.blocks.Count == 0)     //to the start
             return;                     //of the function
         string saveFile = SaveLocation(chunk.world.worldName);
         saveFile += FileName(chunk.pos);
         IFormatter formatter = new BinaryFormatter();
         Stream stream = new FileStream(saveFile, FileMode.Create, FileAccess.Write, FileShare.None);
         formatter.Serialize(stream, save);  //And change this to use save instead of chunk.blocks
         stream.Close();
     }

There, that will create a dictionary with the changes to use for saving, then check that there actually are things to be saved in the file and if not return early and lastly we serialize this dictionary instead of the entire block array.

Now you will also need to go in and delete any saves you made previously with the other system because they will conflict with the new loader we're about to write. Right now it's easy to rewrite the saving and loading code but when you reach a point with players actually using your game changing it will make their files incompatible so you'll have to keep this in mind when updating your game that you include backwards compatibility to upgrade save files.

Now we'll change the load function so remove the line:

         chunk.blocks = (Block[,,]) formatter.Deserialize(stream);

And in its place put this:

         Save save = (Save)formatter.Deserialize(stream);
         foreach (var block in save.blocks)
         {
             chunk.blocks[block.Key.x, block.Key.y, block.Key.z] = block.Value;
         }

Now we deserialize the changed blocks and set their position in the chunk to their value. This is just the modified blocks though so we'll have to do the same generation every time we load the chunk and then apply these changes on top of it so we'll switch to the World.cs script to change the CreateChunk function.

previously we tried to load the chunk from the save and if it succeeded we returned from the function, otherwise we filled the chunk with blocks. Instead we will now fill the chunks with blocks regardless and then try loading the save file on top of it. So first of all remove the lines used for loading before:

         //bool loaded = Serialization.Load(newChunk);    Remove these lines
         //if (loaded)
             //return;

We're left with a function that creates a new chunk from scratch every time. Now at the end of the function add these lines:

         newChunk.SetBlocksUnmodified();
         Serialization.Load(newChunk);

First with the chunk we've generated from scratch we mark all the blocks in it as unmodified, these are not player created they are the default state of the block so the don't need to be saved. Then we apply the changes made by the player to the chunk with the Load function. Because we mark the blocks as unmodified before playing our saved changes on top of the chunk when we call save on it again it will save all the block changes we loaded from the save and any changes we've made since loading.

Another option would be to put the SetBlocksUnmodified after loading. Then if there are any modified blocks they are modified since loading and are not included in the existing save file so to save the changes we would deserialize the save file to a variable and then get the current unmodified blocks and add them to the variable then save that. This would mean that we could save less often because we only save when there are new changes to save but saving would require an extra step. How you do this depends a lot on what type of game you're making so consider this part of your game carefully.

If you create a chunk, edit it and destroy it you should have a fairly small save file you can load by creating the chunk again.

Tutorial part 4Download scripts so far Tutorial part 6

11th Jan, 2015
Alex