Search Unity

Advanced Editor scripting hacks to save you time, part 1

October 18, 2022 in Engine & platform | 15 min. read
A guide to advanced Editor scripting that will save you time | Hero
A guide to advanced Editor scripting that will save you time | Hero
Share

Is this article helpful for you?

Thank you for your feedback!

On most of the projects I’ve seen, there are a lot of tasks developers go through that are repetitive and error-prone, especially when it comes to integrating new art assets. For instance, setting up a character often involves dragging and dropping many asset references, checking checkboxes, and clicking buttons: Set the rig of the model to Humanoid, disable the sRGB of the SDF texture, set the normal maps as normal maps, and the UI textures as sprites. In other words, valuable time is spent and crucial steps can still be missed.

In this two-part article, I’ll walk you through hacks that can help improve this workflow so that your next project runs smoother than your last. To further illustrate this, I’ve created a simple prototype – similar to an RTS – where the units of one team automatically attack enemy buildings and other units. With each scripting hack, I’ll improve one aspect of this process, whether that be the textures or models.

Here’s what the prototype looks like:

Initial build

Hack 1: Organize and automate your assets

The main reason developers have to set up so many small details when importing assets is simple: Unity doesn’t know how you are going to use an asset, so it can’t know what the best settings for it are. If you want to automate some of these tasks, this is the first problem that needs to be addressed.

The simplest way to find out what an asset is for and how it relates to others is by sticking to a specific naming convention and folder structure, such as:

  • Naming convention: We can append things to the name of the asset itself, therefore Shield_BC.png is the base color while Shield_N.png is the normal map.
  • Folder structure: Knight/Animations/Walk.fbx is clearly an animation, while Knight/Models/Knight.fbx is a model, even though they both share the same format (.fbx).

The issue with this is that it only works well in one direction. So while you might already know what an asset is for when given its path, you can’t deduce its path if only given information on what the asset does. Being able to find an asset – for example, the material for a character – is useful when trying to automate the setup for some aspects of the assets. While this can be solved by using a rigid naming convention to ensure that the path is easy to deduce, it’s still susceptible to error. Even if you remember the convention, typos are common.

An interesting approach to solve this is by using labels. You can use an Editor script that parses the paths of assets and assigns them labels accordingly. As the labels are automated, it’s possible to figure out the exact label that an asset will have. You can even look up assets by their label using AssetDatabase.FindAssets.

If you want to automate this sequence, there is a class that can be very handy called the AssetPostprocessor. The AssetPostprocessor receives various messages when Unity imports assets. One of those is OnPostprocessAllAssets, a method that’s called whenever Unity finishes importing assets. It will give you all the paths to the imported assets, providing an opportunity to process those paths. You can write a simple method, like the following, to process them:

private static void OnPostprocessAllAssets(string[] importedAssets, string[] deletedAssets, 
    string[] movedAssets, string[] movedFromAssetPaths)
{
    foreach (var asset in importedAssets)
        ProcessAssetLabels(asset);

    foreach (var asset in movedAssets)
        ProcessAssetLabels(asset);
}

In the case of the prototype, let’s focus on the list of imported assets – both to try and catch new assets, as well as moved assets. After all, as the path changes, we might want to update the labels.

To create the labels, parse the path and look for relevant folders, prefixes, and suffixes of the name, as well as the extensions. Once you have generated the labels, combine them into a single string and set them to the asset.

To assign the labels, load the asset using AssetDatabase.LoadAssetAtPath, then assign its labels with AssetDatabase.SetLabels.

var obj = AssetDatabase.LoadAssetAtPath<Object>(assetPath);
if (obj)
{
    if (labels.Count == 0)
    {
        AssetDatabase.ClearLabels(obj);
        return;
    }

    var oldLabels = AssetDatabase.GetLabels(obj);
    var labelsArray = new string[] { string.Join('-', labels) };
    if (HaveLabelsChanged(oldLabels, labelsArray))
    {
        AssetDatabase.SetLabels(obj, labelsArray);
    }
}

Remember, it’s important to only set labels if they have actually changed. Setting labels will trigger a reimport of the asset, so you don’t want this to happen unless it’s strictly necessary.

If you check this, then the reimport won’t be an issue: Labels are set the first time you import an asset and saved in the .meta file, which means they’re also saved in your version control. A reimport will only be triggered if you rename or move your assets.

With the above steps complete, all assets are automatically labeled, as in the example pictured below.

Screen capture of automatic labeling post-processing in the Unity Editor.
Example material with labels

Hack 2: Determine texture settings and sizes

Importing textures into a project usually involves tweaking the settings for each texture. Is it a regular texture? A normal map? A sprite? Is it linear or sRGB? If you want to change the settings of an asset importer, you can use the AssetPostprocessor once more.

In this case, you’ll want to use the OnPreprocessTexture message, which is called right before importing a texture. This allows you to change the settings of the importer.

When it comes to selecting the right settings for every texture, you need to verify what type of textures you’re working with – which is exactly why labels are key in the first step.

With this information, you can write a simple TexturePreprocessor:

private void OnPreprocessTexture()
{
    var labels = AssetDatabase.GetLabels(assetImporter);

    // 我们只想影响资产
    if (labels.Length == 0 || !labels[0].Contains(AssetClassifier.ART_LABEL))
        return;

    // 获取导入器
    var textureImporter = assetImporter as TextureImporter;
    if (!textureImporter)
        return;

It’s important to ensure that you only run this for textures that have the art label (our own textures). You’ll then get a reference to the importer so that you can set everything up – starting with the texture size.

The AssetPostprocessor has a context property from which you can determine the target platform. As such, you can complete platform-specific changes, like setting the textures to a lower resolution for mobile:

// 设置纹理大小,比如android使用的纹理要小一些
if (context.selectedBuildTarget == BuildTarget.iOS || context.selectedBuildTarget == BuildTarget.Android)
    textureImporter.maxTextureSize = 256;
else
    textureImporter.maxTextureSize = 512;

Next, check the label to see if the texture is a UI texture, and set it accordingly:

// UI纹理是一个特例
if (labels[0].Contains(AssetClassifier.UI_LABEL))
{
    textureImporter.textureType = TextureImporterType.Sprite;
    textureImporter.sRGBTexture = true;
    return;
}

For the rest of the textures, set the values to a default. It’s worth noting that Albedo is the only texture that will have sRGB enabled:

// 我们所有的纹理都是标准纹理,但如果我们使用法线,可以在这里设置它
    textureImporter.textureType = TextureImporterType.Default;
    textureImporter.textureShape = TextureImporterShape.Texture2D;

    // 将它们设置为可读以便在编辑器中处理
    textureImporter.isReadable = true;

    // 只有 albedo 文理是 sRGB
    var texName = Path.GetFileNameWithoutExtension(assetPath);    
    textureImporter.sRGBTexture = labels[0].Contains(AssetClassifier.BASE_COLOR_LABEL)
}

Thanks to the above script, when you drag and drop the new textures into the Editor, they will automatically have the right settings in place.

Textures with settings in place

Hack 3: Take on texture channel packing

“Channel packing” refers to the combination of diverse textures into one by using the different channels. It is common and offers many advantages. For instance, the value of the Red channel is metallic and the value of the Green channel is its smoothness.

However, combining all textures into one requires some extra work from the art team. If the packing needs to change for some reason (i.e., a change in the shader), the art team will have to redo all the textures that are used with that shader.

As you can see, there’s room for improvement here. The approach that I like to use for channel packing is to create a special asset type where you set the “raw” textures and generate a channel-packed texture to use in your materials.

First, I create a dummy file with a specific extension and then use a Scripted Importer that does all the heavy lifting when importing that asset. This is how it works:

  • The importers can have parameters, such as the textures you need to combine.
  • From the importer, you can set the textures as a dependency, which allows the dummy asset to be reimported every time one of the source textures changes. This lets you rebuild the generated textures accordingly.
  • The importer has a version. If you need to change the way that textures are packed, you can modify the importer and bump the version. This will force a regeneration of all the packed textures in your project and everything will be packed in the new way, immediately.
  • A nice side effect of generating things in an importer is that the generated assets only live in the Library folder, so it doesn’t fill up your version control.

To implement this, create a ScriptableObject that will hold the created textures and serve as the result of the importer. In the example, I called this class TexturePack.

With this created, you can begin by declaring the importer class and adding the ScriptedImporterAttribute to define the version and extension associated with the importer:

[ScriptedImporter(0, PathHelpers.TEXTURE_PACK_EXTENSION)]
public class TexturePackImporter : ScriptedImporter
{
}

In the importer, declare the fields you want to use. They will appear in the Inspector, just as MonoBehaviours and ScriptableObjects do:

public LazyLoadReference<Texture2D> albedo;
public LazyLoadReference<Texture2D> playerMap;
public LazyLoadReference<Texture2D> metallic;
public LazyLoadReference<Texture2D> smoothness;
The inspector for the importer.
The Inspector for the importer

With the parameters ready, create new textures from the ones you have set as parameters. Note, however, that in the Preprocessor (from the previous section), we set isReadable to True to do this.

In this prototype, you’ll notice two textures: the Albedo, which has the Albedo in the RGB and a mask for applying the player color in the Alpha, and the Mask texture, which includes the metallic in the Red channel and the smoothness in the Green channel.

While this is perhaps outside the scope of this article, let’s look at how to combine the Albedo and the player mask as an example. First, check to see if the textures are set, and if they are, get their color data. Then set the textures as dependencies using AssetImportContext.DependsOnArtifact. As mentioned above, this will force the object to be recalculated if any of the textures end up changing.

public Texture2D CombineAlbedoPlayer(AssetImportContext ctx)
{
    Color32[] albedoPixels = new Color32[0];
    bool albedoPresent = albedo.isSet;
    if (albedoPresent)
    {
        ctx.DependsOnArtifact(AssetDatabase.GetAssetPath(albedo.asset));
        albedoPixels = albedo.asset.GetPixels32();
    }

    Color32[] playerPixels = new Color32[0];
    bool playerPresent = playerMap.isSet;
    if (playerPresent)
    {
        ctx.DependsOnArtifact(AssetDatabase.GetAssetPath(playerMap.asset));
        playerPixels = playerMap.asset.GetPixels32();
    }

    if (!albedoPresent && !playerPresent)
        return null;

You also need to create a new texture. To do this, get the size from the TexturePreprocessor that you created in the previous section so that it follows the preset restrictions:

var size = TexturePreProcessor.GetMaxSizeForTarget(ctx.selectedBuildTarget);
var newTexture = new Texture2D(size, size, TextureFormat.RGBA32, true, false);
var pixels = new Color32[size * size];

Next, fill in all the data for the new texture. This could be massively optimized by using Jobs and Burst (but that would require an entire article on its own). Here we’ll use a simple loop:

Color32 tmp = new Color32();
Color32 white = new Color32(255, 255, 255, 255);
for (int i = 0; i < pixels.Length; ++i)
{
    var color = albedoPresent ? albedoPixels[i] : white;
    var alpha = playerPresent ? playerPixels[i] : white;
    tmp.r = color.r;
    tmp.g = color.g;
    tmp.b = color.b;
    tmp.a = alpha.r;
    pixels[i] = tmp;
}

Set this data in the texture:

newTexture.SetPixels32(pixels);
 // 将更改应用于mipmap
newTexture.Apply(true, false);
// 压缩纹理
newTexture.Compress(true);
// 设为不可读
newTexture.Apply(true, true);
newTexture.name = "AlbedoPlayer";
// 返回结果
return newTexture;

Now, you can create the method for generating another texture in a very similar way. Once this is ready, create the main body of the importer. In this case, we’ll only create the ScriptableObject that holds the results, creates the textures, and sets the result of the importer through the AssetImportContext.

When you write an importer, all of the assets generated must be registered using AssetImportContext.AddObjectToAsset so that they appear in the project window. Select a main asset using AssetImportContext.SetMainObject. This is what it looks like:

public override void OnImportAsset(AssetImportContext ctx)
{
    var result = ScriptableObject.CreateInstance<TexturePack>();

    result.albedoPlayer = CombineAlbedoPlayer(ctx);
    if (result.albedoPlayer)
        ctx.AddObjectToAsset("albedoPlayer", result.albedoPlayer);

    result.mask = CombineMask(ctx);
    if (result.mask)
        ctx.AddObjectToAsset("mask", result.mask);

    ctx.AddObjectToAsset("result", result);
    ctx.SetMainObject(result);
}

The only thing left to do is to create the dummy assets. As these are custom, you can’t use the CreateAssetMenu attribute. You must make them manually instead.

Using the MenuItem attribute, specify the full path to the create the asset menu, Assets/Create. To create the asset, use ProjectWindowUtil.CreateAssetWithContent, which generates a file with the content you’ve specified and allows the user to input a name for it. It looks like this:

[MenuItem("Assets/Create/Texture Pack", priority = 0)]
private static void CreateAsset()
{

Finally, create the channel-packed textures.

What creating the channel-packed textures looks like

Hack 4: Use the custom shader for materials

Most projects use custom shaders. Sometimes they’re used to add extra effects, like a dissolve effect to fade out defeated enemies, and other times, the shaders implement a custom art style, like toon shaders. Whatever the use case, Unity will create new materials with the default shader, and you will need to change it to use the custom shader.

In this example, the shader used for units has two added features: the dissolve effect and the player color (red and blue in the video prototype). When implementing these in your project, you must ensure that all the buildings and units use the appropriate shader.

To validate that an asset matches certain requirements – in this case, that it uses the right shader – there is another useful class: the AssetModificationProcessor. With AssetModificationProcessor.OnWillSaveAssets, in particular, you’ll be notified when Unity is about to write an asset to disk. This will give you the opportunity to check if the asset is correct and fix it before it’s saved.

Additionally, you can “tell” Unity not to save the asset, which is effective for when the problem you detect cannot be fixed automatically. To accomplish this, create the OnWillSaveAssets method:

private static string[] OnWillSaveAssets(string[] paths)
{
    foreach (string path in paths)
    {
        ProcessMaterial(path);
    }

    // 如果不想保存,移除列表的资产路径
    return paths;
}

To process the assets, check whether they are materials and if they have the right labels. If they match the code below, then you have the correct shader:

private static void ProcessMaterial(string path)
{
    var mat = AssetDatabase.LoadAssetAtPath<Material>(path);
    // 检查是否为材质
    if (!mat)
        return;

    // 检查是building还是unit
    var labels = AssetDatabase.GetLabels(mat);
    if (labels.Length == 0 || !(labels[0].Contains(AssetClassifier.UNIT_LABEL) 
        || labels[0].Contains(AssetClassifier.BUILDING_LABEL)))
        return;

    if (mat.shader.name != UNIT_SHADER_NAME)
    {
        mat.shader = Shader.Find(UNIT_SHADER_NAME);
    }
}

What’s convenient here is that this code is also called when the asset is created, meaning the new material will have the correct shader.

The script in action

As a new feature in Unity 2022, we also have Material Variants. Material Variants are incredibly useful when creating materials for units. In fact, you can create a base material and derive the materials for each unit from there – overriding the relevant fields (like the textures) and inheriting the rest of the properties. This allows for solid defaults for our materials, which can be updated as needed.

Hack 5: Manage your animations

Importing animations is similar to importing textures. There are various settings that need to be established, and some of them can be automated.

Unity imports the materials of all the FBX (.fbx) files by default. For animations, the materials you want to use will either be in the project or in the FBX of the mesh. The extra materials from the animation FBX appear every time you search for materials in the project, adding quite a bit of noise, so it’s worth disabling them.

To set up the rig – that is, choosing between Humanoid and Generic, and in cases where we are using a carefully setup avatar, assigning it – apply the same approach that was applied to textures. But for animations, the message you’ll use is AssetPostprocessor.OnPreprocessModel. This will be called for all FBX files, so you need to discern animation FBX files from model FBX files.

Thanks to the labels you set up earlier, this shouldn’t be too complicated. The method starts much like the one for textures:

private void OnPreprocessModel()
{
    // 我们只想影响动画
    var labels = AssetDatabase.GetLabels(assetImporter);
    if (labels.Length == 0 || !labels[0].Contains(AssetClassifier.ANIMATION_LABEL))
        return;

    // 获取导入器
    var modelImporter = assetImporter as ModelImporter;
    if (!modelImporter)
        return;

    // 我们需要动画
    modelImporter.importAnimation = true;
    // 我们不想要任何材质
    modelImporter.materialImportMode = ModelImporterMaterialImportMode.None;

Next up, you’ll want to use the rig from the mesh FBX, so you need to find that asset. To locate the asset, use the labels once more. In the case of this prototype, animations have labels that end with “animation,” whereas meshes have labels that end with “model.” You can complete a simple replacement to get the label for your model. Once you have the label, find your asset using AssetDatabase.FindAssets with “l:label-name.”

When accessing other assets, there’s something else to consider: It’s possible that, in the middle of the import process, the avatar has not yet been imported when this method is called. If this occurs, the LoadAssetAtPath will return null and you won’t be able to set the avatar. To work around this issue, set a dependency to the path of the avatar. The animation will be imported again once the avatar is imported, and you will be able to set it there.

Putting all of this into code will look something like this:

// 尝试获取avatar
var avatarLabel = labels[0].Replace(AssetClassifier.ANIMATION_LABEL, AssetClassifier.MODEL_LABEL);
var possibleModels = AssetDatabase.FindAssets("l:" + avatarLabel);
Avatar avatar = null;
if (possibleModels.Length > 0)
{
    var avatarPath = AssetDatabase.GUIDToAssetPath(possibleModels[0]);
    avatar = AssetDatabase.LoadAssetAtPath<Avatar>(avatarPath);

    if (!avatar)
        context.DependsOnArtifact(avatarPath);
}

modelImporter.animationType = ModelImporterAnimationType.Generic;
modelImporter.sourceAvatar = avatar;
modelImporter.avatarSetup = ModelImporterAvatarSetup.CopyFromOther;

Now you can drag the animations into the right folder, and if your mesh is ready, each one will be set up automatically. But if there isn’t an avatar available when you import the animations, the project won’t be able to pick it up once it’s created. Instead, you’ll need to reimport the animation manually after creating it. This can be done by right-clicking the folder with the animations and selecting Reimport.

You can see all of this in the sample video below.

Animations sample video

Hack 6: Set up the mesh using FBX importers

Using exactly the same ideas from the previous sections, you’ll want to set up the models you are going to use. In this case, employ AssetPostrocessor.OnPreprocessModel to set the importer settings for this model.

For the prototype, I’ve set the importer to not generate materials (I will use the ones I’ve created in the project) and checked whether the model is a unit or a building (by verifying the label, as always). The units are set to generate an avatar, but the avatar creation for the buildings is disabled, as the buildings aren’t animated.

For your project, you might want to set the materials and animators (and anything else you want to add) when importing the model. This way, the Prefab generated by the importer is ready for immediate use.

To do this, use the AssetPostprocessor.OnPostprocessModel method. This method is called after a model is finished importing. It receives the Prefab that has been generated as a parameter, which lets us modify the Prefab however we want.

For the prototype, I found the material and Animation Controller by matching the label, just as I located the avatar for the animations. With the Renderer and Animator in the Prefab, I set the material and the controller as in normal gameplay.

You can then drop the model into your project and it will be ready to drop into any scene. Except we haven’t set any gameplay-related components, which I’ll address in the second part of this blog.

Drag and drop the model into your project and it will be ready to drop into any scene.

Until next time…

With these advanced scripting tips, you’re just about game ready. Stay tuned for the next installment in this two-part Tech from the Trenches article, which will cover hacks for balancing game data and more.

If you would like to discuss the article, or share your ideas after reading it, head on over to our Scripting forum. You can also connect with me on Twitter at @CaballolD.

October 18, 2022 in Engine & platform | 15 min. read

Is this article helpful for you?

Thank you for your feedback!

Related Posts