Have Unity Support Your Custom Files (Part 5/6)

Miijii
Miijii’s Unified Works
14 min readMar 5, 2023

Learn to take more control of Unity by giving it some perzonalized customizations!

A Basic “Version Control” component that changes the Version Semantic in your Player Settings.

Grab some popcorn! Make some coffee (or tea)! Turn on a show on Netflix. Because this will be a long one.

When it comes to improving a developer’s workflow, Unity provides you with a numerous features and tools. Have you ever created a component, and you realized that it’s structure can be… well… nicer?

Image trying to navigate through all of this. 🤢

When it comes to creating components or Scriptable Objects, we may need to include a plethora of fields and properties (though if that’s the cause, then you may need to look back into your software architecture and do some refactoring unless it can’t be helped). Depending on the total fields on an object (as well as if it’s public or private), Unity will automatically create UI fields to be able to change those values from the Editor. This can be helpful for components having small amounts of fields and properties. However, the UI becomes more complex when it comes to an object’s scalibility. As more requirements are added, the more complicated the UI will become.

As I said before, Unity provides us will tools and features to give us more control over our development workflow. One of those features being Custom Editors! With Custom Editors, you are able to restructure what you see on the Inspector tab based on a specified object type. Not only that, but you can create your own tabs or windows with unique controls and layouts to make; creating and modifying your assets more intuitive!

An Emitter Profile Asset customized for simplicity. (from Simon Albou’s Bullet Pro) 🎮

It’s not a necessary skill persay. However, you should every once in a while experiment with Custom Editors so that you can be familiarized with them. And if you are able to perfect them, it’ll end up making your development cycle much smoother. The only downside that I can muster up is scalability. Yes, you can restructure the inspector to accomadate for the “scalability of your object”, however, Unity has to process all of the UIElements that are present in your Custom Editor.

Optimization will be the most important part of creating your own Custom Editor. For example, with Simon Albou’s “BulletPro” package for creating danmakus, if you were to create too much branches in the Bullet Hierarchy, you’ll soon see that Unity will loss a significant amount of performance.

This problem may have been resolved with recent updates. However, it comes to show that you must be careful with how much information you show in your Custom Editor. It otherwise defeats the purpose of creating one in the first place (to improve your development cycle and workflow).

A VERY long Bullet Hierarchy structure. Try to avoid. ☠️

Now, I haven’t perfected the process of creating a Custom Editor. In fact, there’s a lot of static classes that exist that can change and modify the way your Custom Editor looks (and can also work inside your Scene or Game tabs). We won’t be doing too much for our Digicard example (luckily). Let’s start by addressing the “UnityEditor” namespace.

The “UnityEditor” namespace contains the implementations of editor-specific APIs to the developer. This is actually where our AssetDatabase, ScriptedImporter, and EditorSettings classes are implemented from. Howeever, it also has classes for creating our Custom Editor, as well as creating fields and properties for it. The ones we want to focus on are the following classes:

  • EditorGUILayout
  • EditorGUIUtility
  • EditorGUI
  • SerializedObject
  • SerializedProperty
  • Editor
  • CustomEditor

There are other classes that we want to keep in mind, however they are implemented in the “UnityEngine” namespace:

  • GUILayout
  • GUIContent
  • GUI

For being able to configure our Digicard asset through the Inspector, the only classes we’ll be utilizing are SerializedProperty, Editor, CustomEditor, EditorGUILayout, GUILayout, and GUIContent.

We’ll be doing most of the structuring of our Custom Editor with EditorGUILayout and GUILayout. But as you may know, EditorGUILayout is implemented in the UnityEditor namespace, while the GUILayout is implemented in the UnityEngine namespace. Why is this?!

This is something that’s very confusing when it comes to creating your Custom Editor. In fact, this aspect of Unity most often scares away beginners!

I’m hear to say, 安心してください!Do not worry! Creating your own Custom Editor is much easier than preceived. You’ll see that when we create a Custom Editor for our Digicard Asset!

Satania-San from “Gabrial Dropout” giving you support! 💞(Tenor)

Let’s go ahead and create our Custom Editor! The first thing we have to do is create a new folder inside our Unity project called “Editor”. Unity will immediately know that all Editor-based features will be stored in here, and will use it accordingly. A good thing on this is you can create an “Editor” folder anywhere in your project, and create as many of them as you want. This is because Unity compiles a seperate assembly specific for Editor-based code-behind. We’ll see this when we actually create our Custom Editor!

Next, right-click and create a new C# Script called “DigicardAssetImporterInspector”. Select your created C# Script, and make sure that it derives from “Editor”. One line above your class, you want to use the “CustomEditor” attribute. We want to target the type “DigicardAssetImporter”. Why not “DigicardAsset”? We’ll explain the reason why much later in the article.

using UnityEditor;
using UnityEngine;

[CustomEditor(typeof(DigicardAssetImporter))]
public sealed class DigicardAssetImporterInspector : Editor
{

}

The “Editor” class (in combination to using the CustomEditor attribute, and setting a target type) will provide us with all the information regarding our object. In particalar, it’ll give us data on the object being viewed or serialized (the serializedObject), as well as all the exposible properties that it has.

In order to have access to those property, you would invoke the“FindProperty” method on your serializedObject. The first thing we need to do is cache all of our SerializedProperty. It’s common practice to do the caching when our Inspecter shows up. This is all done inside the OnEnable method.

using UnityEditor;
using UnityEngine;

[CustomEditor(typeof(DigicardAssetImporter))]
public sealed class DigicardAssetImporterInspector : Editor
{
SerializedProperty m_name;
SerializedProperty type;
SerializedProperty properties;
SerializedProperty coverArt;
SerializedProperty description;

private void OnEnable()
{
m_name = serializedObject.FindProperty("cardName");
type = serializedObject.FindProperty("cardType");
properties = serializedObject.FindProperty("cardProperties");
description = serializedObject.FindProperty("cardDescription");
coverArt = serializedObject.FindProperty("cardCoverArt");
}
}

With our Serialized Properties now cached, we can finally make use of them by rendering them in our Custom Editor. This can be done by process all of our logic in an overridden method called OnInsepectorGUI().

To add a level of seperation, we’ll create a new method where our actual processing code will happen. We’ll called it “DrawImporterGUI”. When at any point we make changes to our Serialized Property, we’ll need to be sure to apply those modifications to our serializedObject. We do this by invoking the “ApplyModifiedProperties()” method. You should then get something like this:

using UnityEditor;
using UnityEngine;

[CustomEditor(typeof(DigicardAssetImporter))]
public sealed class DigicardAssetImporterInspector : Editor
{
SerializedProperty m_name;
SerializedProperty type;
SerializedProperty properties;
SerializedProperty coverArt;
SerializedProperty description;

private void OnEnable()
{
m_name = serializedObject.FindProperty("cardName");
type = serializedObject.FindProperty("cardType");
properties = serializedObject.FindProperty("cardProperties");
description = serializedObject.FindProperty("cardDescription");
coverArt = serializedObject.FindProperty("cardCoverArt");
}

public override void OnInspectorGUI()
{
DrawImporterGUI();
serializedObject.ApplyModifiedProperties();
}

private void DrawImporterGUI()
{
//TODO: Create Property Fields of all Digicard Asset fields.
}
}

Now, for the scary part. There’s tons of controls you can use when you are creating your Custom Editor. You can add Toggles, ReorderableLists, Buttons, a ColorPicker, etc. Most of these can easily be achieved using EditorGUILayout. With this static class, you don’t have to manually position your UIElements (which is one of the scary parts of creating a custom class). Manually positioning and scaling your UIElements comes with it’s own benefits of course. However, it’s a bit of a learning curve. Still, you can do a miraculous amount of customization with just EditorGUILayout alone because it provides you with many options. Click here to see a whole list of them. Also see a list of static methods implements in GUILayout (which much of them are the same). For this, we’ll just need to create a button for our Custom Editor. So long you have your Serialized Properties cached and on-the-ready, you can easily make customizations.

Again, our goal is to be able to manipulate our Digicard Asset, since by default our controls are disable. So, we’ll just be needing fields to be able to interact with. With that said, we’ll be using the static method “PropertyField”. We’ll call this method for each of our Serialized Properties, and we’ll also provide a GUIContent (alternatively, you can just use a string literal as the content). We’ll then create some space between our properties fields and our “Save” button.

For our “Save” button, this required an “if” statement. This is because GUILayout.Button will return us either a “true” (if the button has been clicked) or false. If the button has been pressed, we want to access the “target object” from our serializedObject. That “target object” happens to be what we set for our CustomEditor attribute. We’ll be casting it to the type we need using the “as” keyword (you could opt for casting without it if it’s your preference).

This should then give us our selected “importer” (as in the object we’ve selected in the Projects tab). We should then be able to Save the changed values, and update our Digicard file promptly. Feel free to add a null-check after getting our “importer” (which in an actual work environment, you should null-check your logic when possible).

using UnityEditor;
using UnityEngine;

[CustomEditor(typeof(DigicardAssetImporter))]
public sealed class DigicardAssetImporterInspector : Editor
{
SerializedProperty m_name;
SerializedProperty type;
SerializedProperty properties;
SerializedProperty coverArt;
SerializedProperty description;

private void OnEnable()
{
m_name = serializedObject.FindProperty("cardName");
type = serializedObject.FindProperty("cardType");
properties = serializedObject.FindProperty("cardProperties");
description = serializedObject.FindProperty("cardDescription");
coverArt = serializedObject.FindProperty("cardCoverArt");
}

public override void OnInspectorGUI()
{
DrawImporterGUI();
base.serializedObject.ApplyModifiedProperties();
}

private void DrawImporterGUI()
{
//TODO: Create Property Fields of all Digicard Asset fields.
EditorGUILayout.PropertyField(m_name, new GUIContent("Card Name"));
EditorGUILayout.PropertyField(type, new GUIContent("Card Type"));
EditorGUILayout.PropertyField(properties, new GUIContent("Card Properties"), true);
EditorGUILayout.PropertyField(coverArt, new GUIContent("Card Cover Art"));
EditorGUILayout.PropertyField(description, new GUIContent("Card Description"));

EditorGUILayout.Space(2);

if(GUILayout.Button("Save"))
{
var importer = (serializedObject.targetObject as DigicardAssetImporter);
importer.Save();
}
}
}

That’s all good and all, but there’s a bit of refactoring that needs to be done in order to make our Custom Editor work. I will explain the reasoning why the data is restructured the way it is. Let’s first head back to our DigicardAsset ScriptableObject. We want to focus between lines 22 and 29.

There has been slight changes to our DigicardAsset class. 📁

You may have noticed that we have changed our original [SerializeField] with instead [HideInInspector]. This is our first change required to support modifying and configurating our Digicard file through the editor. As you may had remembered before, when we implemented our DigicardAssetImporter and attached our DigicardAsset object to it, we weren’t allowed to make changes to our object. All of the UIElements for modifying the ScriptableObject was deemed unactive.

We can’t make changed to our Digicard. 😭

Changing our properties to be “serialized” in our Inspector to “being hidden” from the inspector will remove all the UIElements that were inactive. Not only that, but we also changed our field to be “public” instead of “private”. This is an important step, because we’ll be needing to access these fields later on to effectively Save and Load our file, and give those files to the DigicardAsset ScriptableObject. Remember, the importer has “embedded” or “attached” our ScriptableObject with what was being imported (that being our .digicard file). We still need to be able to modify that object in some way.

For now, that’s the only change that we have made for the DigicardAsset. Just so that we’re at the same state of progress, I’ll leave the updated DigicardAsset code below (if you had refactored this and seperated all classes in this code, be sure to head to those classes and make the proper changes):

using System;
using System.IO;
using UnityEditor;
using UnityEngine;

public sealed class DigicardAsset : ScriptableObject
{
private int m_instanceID;
public int InstanceID
{
get
{
if (m_instanceID == 0)
m_instanceID = GetInstanceID();
return m_instanceID;
}
}
private int m_hashCode;
public int HashCode => InstanceID.GetHashCode();

// Imagine we have field and properties representing our file type
[HideInInspector] public string filePath;

// Exposible Field
[HideInInspector] public string cardName;
[HideInInspector] public CardType cardType;
[HideInInspector] public CardProps[] cardProperties;
[HideInInspector] public Sprite cardCoverArt;
[HideInInspector] public string cardDescription;

private DigicardData data = new();

private void OnEnable()
{
cardName = name;
}

public override int GetHashCode()
{
return HashCode + data.GetHashCode();
}

/// <summary>
/// Save Digicard Data
/// </summary>
public void Save()
{
GenerateNewCard();

var jsonString = JsonUtility.ToJson(data, true);

if (filePath == null)
{
Debug.LogError("Failed to save Digicard");
return;
}

using StreamWriter streamWriter = new(filePath);
streamWriter.Write(jsonString);
}

private void GenerateNewCard()
{
// Only run this if our data
// object is void. This prevents
// unnecessary memory allocation.
data ??= new DigicardData()
{
name = cardName,
type = cardType,
properties = cardProperties,
description = cardDescription,
coverArtPath = AssetDatabase.GetAssetPath(cardCoverArt)
};

// We then reuse our data, and change the field
// accordingly.
data.name = cardName;
data.type = cardType;
data.properties = cardProperties;
data.description = cardDescription;
data.coverArtPath = AssetDatabase.GetAssetPath(cardCoverArt);
}

public void Load()
{
using StreamReader streamReader = new StreamReader(filePath);
var jsonString = streamReader.ReadToEnd();
data = JsonUtility.FromJson<DigicardData>(jsonString);

cardName = data.name;
cardType = data.type;
cardProperties = data.properties;
cardDescription = data.description;

if (data.coverArtPath == string.Empty) return;
RequestingImage(data.coverArtPath);
}

private bool RequestingImage(string path)
{
Sprite image = ConvertTextureToSprite(LoadTexture(path), 100f, SpriteMeshType.Tight);

cardCoverArt = image;

return true;
}

private Sprite ConvertTextureToSprite(Texture2D _texture, float _pixelPerUnit = 100.0f, SpriteMeshType _spriteType = SpriteMeshType.Tight)
{

Sprite newSprite = null;
//Converts a Texture2D to a sprite, assign this texture to a new sprite and return its reference
newSprite = Sprite.Create(_texture, new Rect(0, 0, _texture.width, _texture.height), new Vector2(0, 0), _pixelPerUnit, 0, _spriteType);

return newSprite;
}

private Texture2D LoadTexture(string _filePath)
{
/*We'll load a png or jpg file from disk to a Texture2D
If we fail at doing so, return null.*/

Texture2D tex2D;

//We want to read the binary data of this file,
//in order to know the formatting. With the formatting,
//we'll use the data to create the image that we requested
byte[] fileData;

if (File.Exists(_filePath))
{
fileData = File.ReadAllBytes(_filePath);
tex2D = new Texture2D(2, 2);
if (tex2D.LoadImage(fileData))
return tex2D;
}

return null;
}
}

public enum CardType
{
Normal,
Spell,
Summons
}

[Serializable]
public sealed class DigicardData
{
public string name;
public CardType type;
public CardProps[] properties;
public string description;
public string coverArtPath;
}

[Serializable]
public sealed class CardProps
{
public string name;
public int value;
public string description;
}

Next, we have to modify our DigicardAssetImporter class a bit more than our DigicardAsset class.

We will be needing to create a Custom Editor not for our DigicardAsset, but for our DigicardAssetImporter instead. If we try to create a Custom Editor for our DigicardAsset, this will not work because our Importer is simple “previewing” the data of what it just imported. This means by creating a Custom Editor targeting our DigicardAsset class, those UIElements will also be disabled. In order to combat this, we will have to create a Custom Editor for our DigicardAssetImporter instead.

What does that mean for us? I wish I didn’t have to say it, but we’ll have to duplicate the exact fields that we had for our DigicardAsset. It’s not entirely bad, but if you can, you want to avoid doing unnecessary duplications of code. However, this will allow us to cache the information from our DigicardAsset and be able to do whatever we want with it. The good part to this is, we have information on the ScriptableObject’s path!

using System.Linq;
using UnityEditor;
using UnityEditor.AssetImporters;
using UnityEngine;

/// <summary>
/// Import any files with the .digicard extension
/// </summary>
[ScriptedImporter(1, "digicard", AllowCaching = true)]
public sealed class DigicardAssetImporter : ScriptedImporter
{
private DigicardAsset asset;

public string assetReferencePath;

// Exposible Field
public string cardName;
public CardType cardType;
public CardProps[] cardProperties;
public Sprite cardCoverArt;
public string cardDescription;

private const string DigicardExtension = "digicard";

public override void OnImportAsset(AssetImportContext ctx)
{
// Create our DigicardAsset
asset = ScriptableObject.CreateInstance<DigicardAsset>();
var assetPath = ctx.assetPath;
assetReferencePath = assetPath;
asset.filePath = assetPath;
asset.Load();

PopulateImporterFields();

ctx.AddObjectToAsset(GUID.Generate().ToString(), asset);
ctx.SetMainObject(asset);

// If extension not included in our project
// add it.
TryIncludeDigicardExtension();
}

public void PopulateImporterFields()
{
cardName = asset.cardName;
cardType = asset.cardType;
cardProperties = asset.cardProperties;
cardCoverArt = asset.cardCoverArt;
cardDescription = asset.cardDescription;
}

public void Save()
{
asset = AssetDatabase.LoadAssetAtPath<DigicardAsset>(assetReferencePath);
asset.cardName = cardName;
asset.cardType = cardType;
asset.cardProperties = cardProperties;
asset.cardCoverArt = cardCoverArt;
asset.cardDescription = cardDescription;
asset.Save();
PopulateImporterFields();
}

// You can add this if you want to prevent from
// manually including your new extension in the
// Player Settings
private static void TryIncludeDigicardExtension()
{
if (EditorSettings.projectGenerationUserExtensions.Contains(DigicardExtension)) return;
var list = EditorSettings.projectGenerationUserExtensions.ToList();
list.Add(DigicardExtension);
EditorSettings.projectGenerationUserExtensions = list.ToArray();
}
}

So not only will we be creating the same fields as our DigicardAsset class, but we’ll add another file pointing us to that particular asset to modify. You’re probably wondering, “Why didn’t you cache the information of the DigicardAsset itself?”

If that was what you were wondering, then that’s a very good question! We usually would just cache the object in question as oppose to caching the fields and properties of that field (thus giving us less work). Unfortunately, the reference to that asset when initially being important is never cached! This is simply be because it’s not considered a primitive type. More specifically, it is at least not a type that doesn’t inherit from Monobehaviour).

Each time an import event is called from any Importers (include our Custom Importer), a refresh of all of our existing assets inside our project (a.k.a. the AssetDatabase) is invoked. The invocation priority (or the value in which a event should be triggered first) is higher than the refreshing of the AssetDatabase. It’ll import, then update and refresh the AssetDatabase. This result in losing our cached data for our DigicardAsset instance. This was confirmed when I had stepped through the code while making these changes. I even tried setting the cached data to be publically accessed, but the data would still be lost as well.

So instead, we load the asset using the cachedReferencePath that’s saved when importing an asset. We then modify the fields of our DigicardAsset object, and then we save. You may have noticed another method called “PopulateImporterFields”. This is for updating our cache for when our Digicard file is modified, effectively keeping our Custom Editor up-to-date on any changes made to our Digicard Asset.

Now, all of these changes requires us to delete any existing Digicard files that we have. If we go ahead and create a new Digicard asset after deleting existing ones, you should now be able to make modifications to it.

A Digicard called “Aging Leaf”, but this time we can make changes to it.

You can go ahead and play around with the settings. Change some properties, add new ones, change the name, change the card type, add a cover art! Do whatever you want! If you then save your changes and view your Digicard file inside a text editor, you should see that it updates!

Changing our fields and saving it reflects in our Digicard file.

If you’ve made it to the end, CONGRATULATIONS! I apologize that this particular article is so long. I wanted to be sure to put in as much information as possible, as well as give some insight to some of the decisions I made. You may had noticed that despite our Digicard asset does reflect our changes from the Editor, it still doesn’t behave entirely how we want it!

They always say 80% of a problem is easy to solve, while the remaining 20% is the most difficult. That’s what our final part will be about: polishing and improving what we have. Part 6 will also be the part where we FINALLY add our own icon for our Custom File asset (OH EXCITING)!

If you have come this far, I would like to property thank you for your support. You reading these articles all the way through helps me a lot. You have no idea. Though not everyone likes to read a whole sea of text. So after I’ve finished posting this part and part 6, I’ll make a video equivalent on my Youtube. That way, you don’t have to completely strain your eyes when reading this.

But again, I appreciate the engagement and the support. If you liked what you’ve read, or had taken something from this, feel free to like this article. Consider following me so that you can be given more Unity/Game Development tips, guides, and tricks. コーディング諦めないぞ!Don’t Stop Coding!

--

--

Miijii
Miijii’s Unified Works

Miijii (Pen-Name) | Game Developer | Developing XVNML, XVNML2U, and Tomakunihahaji