Sunday, September 6, 2020

A Look At Scriptable Objects Part 2

In my last post, I introduced my recent journey into Unity's Scriptable Objects.  Since then I have been working to convert the tree objects and the tile engine to use them in place of Singleton classes that store tree and tile data in static lists.  I have now completed parts of this refactoring and am quite impressed with the new workflow, the added benefits, and the surprising performance boost I have seen moving to scriptable objects.  From here onward, I will abbreviate the term Scriptable Object to simply (SO) for brevity.

The Refactoring Process

I decided to refactor my tree game objects first since the tree code was less work than the tile engine. I started by creating an abstract base class which derived from (SO).  All my other (SO) classes derive from my base class.  I used this base class to store properties and fields that were common to all the (SO) classes I would eventually create.  The class needed to be abstract because I had no intention of instantiating this base (SO) class by itself.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
    public abstract class STBaseObject : ScriptableObject
    {
        public string Id { get; set; }
        public string Name;
        public string FriendlyName;
        public STBaseType BaseType;        
        public GameObject DefaultPrefab;
        public GameObject InventoryPrefab;
        public float AdjustedXPos;
        public float AdjustedYPos;
        public int HighestMapLevel;
        public int LowestMapLevel;
        [TextArea(1, 20)]
        public string Description;        
    }    

The next (SO) class was my STTreeObject class.  This class would hold fields and properties that were unique to my trees.  Up to this point, all my tree data was stored in a public class that held the properties for my trees.  This class was accessed by another class which existed to simply to instantiate the list of trees at runtime.  That class was instantiated once as a public property on one of my Singleton classes, which also was only instantiated once.  What I am describing is a Singleton data storage mechanism.  Getting back to the (SO) alternative, have a look at the top portion of the new class showing the CreateAssetMenu attribute.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
[CreateAssetMenu(fileName = "TreeObject", menuName = "SurviveAndThrive/Flora/TreeObject", order = 1)]
    public class STTreeObject : STBaseObject
    {
        public int AssetId;
        public GameObject Sapling;
        public GameObject Ripe;
        public string FellingSound;
        public float FellSoundTimer;
        public STFloraAssetType AssetType;
        public STFloraAssetDistributionType DistributionMethod;
        public STFloraAssetAgeType MinimumAge;
        public STFloraAssetAgeType MaximumAge;
        public STFloraAssetAgeType MaturityAge;
        public STFloraAssetGrowthRateType GrowthRate;
        public STFloraAssetGrowthRateType FlowerRate;
        public STFloraAssetGrowthRateType SeedRate;

The class attribute is important because it allowed me to create a (SO) file in the Unity Editor by creating a menu item for it under Unity's Assets => Create menu.  Notice the fileName is the name of the (SO).  The menuName is the location within the menu structure where the (SO) will appear. 

Scriptable Object Menu

At first, I thought my tree class and (SO) tree class would be basically the same.  However, once I started refactoring, I realized some of the string values I was using to locate my tree objects were no longer needed because I could directly reference them as a GameObject on my (SO) class.  Here is what a new TreeObject looks like from the Inspector.
A New TreeObject

The New Workflow

There is much to be appreciated in the screenshot above.  For one, it is a lot easier to keep track of my tree data using the Project and Inspector tabs in Unity as opposed to scrolling down 1600+ lines of code in the class I was using to store this data before.  In a glance, I can see how many trees I have and I can compare the property settings of each one to the others.  Plus, by selecting a tree (SO) and locking the inspector, I can navigate to my prefabs and drag-and-drop them onto the (SO).  This means I no longer have to load those prefabs from the disk each time I need to instantiate a new tree in the game.  Yes, it is tedious to transfer all the information from the class to the (SO) files, but it is no less work than using the classes.  

The Added Benefits

This brings me to the benefits I have noticed using (SO) files instead of static classes.  Like I mentioned earlier, I was able to switch from storing and using strings in the tree class which held paths and filenames to using hard references in the (SO) file for things like the DefaultPrefab, InventoryPrefab, and Sapling and Ripe variants.  Another amazing, and I do mean AMAZING benefit over static class storage is the fact that all of the tree data can be altered at runtime.  For example, if I need to increase the number of logs that are produced from chopping down a particular type of tree, all I have to do is change the value in the inspector.  The next time I chop down that type of tree, it will produce the altered count of logs automatically.  This is perhaps the best overall benefit I have seen to using (SO) over other methods of storage of static data.

The Performance Boost

Once I had converted to using (SO) for the trees, I proceeded to build out the tile engine to use (SO) as well.  There is a nice method in my MapManager class which creates the preview map in the game.  I chose that as the first step to refactoring the tile engine.  What follows are comparisons between the pre-scriptable-object version of the game and the current using the new (SO) system.  This test involved loading an existing map template and rendering the preview of it.  The map is 1 MILLION tiles, being 1000 x 1000 tiles in size, rendered on a single Unity tilemap.  Notice in the Console the Start and Stop times.  These times captured how long it took to generate the preview map using the old and new systems.

Pre-SO-Test
35 Seconds

I didn't think 35 seconds was all that bad considering the map engine had to generate a million value noise map, distribute the tile types based on the user's chosen weight for each type, and then loop through to actually create the tilemap tiles using a custom rule-tile method.  But I was not really prepared for the results of the (SO) run.

SO Test
23 Seconds

The same template using (SO) files took only 23 seconds to create and render.  Doing the math, the pre-SO test processed 28,571 tiles per second.  The SO test processed 43,478 tiles per second.  That is a whopping 35% increase in performance! Now to be fair, there is a critical optimization that I used for the (SO) refactoring.  With the old classes, I had to build out a file path and filename to the required tile and then instantiate it with a call to Resources.Load<Tile>().  This was necessary simply because of the huge amount of tiles to manage.  But with the (SO) solution, I chose instead to reference all 15 tiles of each terrain type in their respective (SO) files.  This enabled me to reference all of the tile variations in a clean and organized manner. I believe the majority of the performance boost was due to this optimization as loading from a file system is always more costly than from memory.  The Task Manager didn't show significant differences, but here they are. 

Pre-SO-Test

SO Test

I am very happy that I decided to learn about (SO) and how to use them for static game data storage.  There are many other uses for them that I am sure I will discover.  And, I still have quite a bit of refactoring left to do.  The preview map is just a small part of the map engine.  I will likely follow-up this post with a part three to summarize any future findings.  But one thing is sure.  I will definitely be using Scriptable Objects as much as I can.  That real-time tweaking feature is just fantastic!  

Until next time...

[The Next Day UPDATE!]

I have spent pretty much all day working on refactoring the entire map engine to use (SO).  After some debugging, I ran some tests and I want to update this post to show the dramatic results of moving from Singleton class stores with resource loading to (SO) managed data.  I created a new test map and placed a stopwatch on the initial chunk loading, which loads all 9 chunks around the player in the initial load.  Here are the results.

Pre-SO Chunk Load Times

Post-SO Chunk Load Times

The above comparison shows before the (SO) refactoring, my chunk engine loaded the 9 chunks around the player in 2.91 seconds.  After implementing the (SO) solution, the 9 chunks loaded in only .73 seconds or 730 milliseconds!  I noticed also that the (SO) solution was extremely consistent across almost all the chunks after the initial chunk loaded.  Whereas the pre-SO load times are wildly different, ranging from 870 milliseconds down to 15 milliseconds.  Needless to say, I am incredibly impressed with the results of this work.  The (SO) path has been very rewarding thus far.  I will definitely be looking for every opportunity to use Scriptable Objects in as much of my game as possible.  I hope this has been as interesting to you as it has been rewarding for me.  

Until next time...

2 comments:

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