Convenient work with console utilities in Unity / Habr

Convenient work with console utilities in Unity

Hello everyone My name is Grigory Dyadichenko, I am engaged in producing digital projects. Today I would like to talk about the possibilities of extending the Unity editor, and how you can simplify your work using the example of turning nginx on and off from Unity. We will walk through the topic of building AssetBundles and working with processes in C#.

I work quite a lot with virtual and augmented reality. And in general, with testing something on devices where not everything can be done in the Unity editor. And so I asked myself the question: “How would I simplify the work process for myself?”. My main machine is on Windows, and to build builds on iOS, I have to drive the assembly to a macbook via shared folder via Wifi. It would be possible to configure CI&CD, but in terms of iterations it is slower than this way. But in addition, there is such a task as content testing, where there is little artistic sense in reassembling the build. And you can simply upload content to the device on the local network through the Asset Bundles mechanism. Well, well, how not to spend half a day on such a task?

But in order to deliver bundles, you need a web server. At first I tried to do it using the server from this article, but it’s too clumsy, and it will be hard to support gzip there. Therefore , I immediately came to mind nginx. But since I want to do both for myself and for people, it would be necessary to simplify the launch of nginx for Unity developers, and also so that it turns off when the editor is turned off and, in general, manage it from the editor (well, within our task). Well, the solution has been invented – it’s time to do it.

NGINX integration into Unity project

In general, the ability to run all sorts of console utilities or exe files from the editor is a pretty useful thing. Thus, you can quickly expand the editor’s capabilities without complex development, but in addition to the editor, if the project is for a desktop platform, the skill to work with the Process class allows you to also access the necessary utilities already in the build. We will analyze the case with the editor. Well, let’s write a class to run our nginx.

To begin with, we’ll just write the start of the process and analyze what it does:

Process startup code

private void ExecuteCommand (string pathToExe, string args)
{
  Process process = new Process();
  ProcessStartInfo startInfo = new ProcessStartInfo();
  startInfo.WindowStyle = ProcessWindowStyle.Hidden;
  startInfo.FileName = pathToExe;
  startInfo.Arguments = args;
  startInfo.UseShellExecute = false;
  var path = Path.GetDirectoryName(NginxPath) ?? string.Empty;

  if (!string.IsNullOrEmpty(path))
  {
  	startInfo.WorkingDirectory = path;
  }
  startInfo.CreateNoWindow = true;
  process.StartInfo = startInfo;
  process.Start();
  Debug.Log($"Success {pathToExe} {args}");
}

In this code block, we:
1. Creating a new process
2. Set the startup parameters. From the interesting:
startInfo.WindowStyle = ProcessWindowStyle.Hidden; — so that we don’t have a console.
StartInfo.WorkingDirectory = path; — some processes depend on the working directory.
startInfo.CreateNoWindow = true; — whether to start the process in a new window.
3. Start the process

The start of the process is written, now we need the path to the executable file. Of course, you can hack it, but Unity has a tool that is much more convenient. The thing is that UnityEngine.Set Object as a field in the inspector, and at the same time all files and folders of the project are the inheritor of UnityEngine.Object. Then through AssetDatabase.GetAssetPath can get the full path to the object. And use it for our purposes. Here is the full code of our nginx setup object:

NGINXSettings code

using System;
using System.Diagnostics;
using System.IO;
using UnityEditor;
using UnityEngine;
using Debug = UnityEngine.Debug;
using Object = UnityEngine.Object;

[CreateAssetMenu(fileName = "NGINXSettings", menuName = "NGINX/Settings")]
public class NGINXSettings : ScriptableObject
{
    public const int ServerPort = 10020;
    public const string LogPath = "logs";
    public const string PidFileName = "nginx.pid";
    public Object Nginx;
    public string NginxPath => Path.GetFullPath(AssetDatabase.GetAssetPath(Nginx));
    
    public void StartNginx()
    {
        var dir = Path.GetDirectoryName(NginxPath) ?? string.Empty;
        if (!File.Exists(Path.Combine(dir, LogPath, PidFileName)))
        {
            ExecuteCommand(NginxPath , "");
        }
        else
        {
            Debug.Log("Nginx already started!");
        }
    }

    public void StopNginx()
    {
        ExecuteCommand(NginxPath, "-s quit");
    }
    private void ExecuteCommand (string pathToExe, string args)
    {
        Process process = new Process();
        ProcessStartInfo startInfo = new ProcessStartInfo();
        startInfo.WindowStyle = ProcessWindowStyle.Hidden;
        startInfo.FileName = pathToExe;
        startInfo.Arguments = args;
        startInfo.UseShellExecute = false;
        var path = Path.GetDirectoryName(NginxPath) ?? string.Empty;

        if (!string.IsNullOrEmpty(path))
        {
            startInfo.WorkingDirectory = path;
        }
        startInfo.CreateNoWindow = true;
        process.EnableRaisingEvents = true;
        process.StartInfo = startInfo;
        process.Start();
        Debug.Log($"Success {pathToExe} {args}");
    }
    
}

In this case, for the convenience of working in the editor, we will make a Scriptable Object with a custom inspector. I have analyzed SO and custom inspectors in more detail here. However, then I did it a little differently, so here it’s worth saying what the CreateAssetMenu attribute does. It allows you to create SO by right-clicking in the editor.

But the custom inspectors there are disassembled quite well, so we will take the following code for granted so that we have buttons. Moreover, it is elementary:

NGINXSettingsCustomEditor code

using UnityEditor;
using UnityEngine;

[CustomEditor(typeof(NGINXSettings))]
public class NGINXSettingsCustomEditor : Editor
{
    public override void OnInspectorGUI()
    {
        var nginxSettings = target as NGINXSettings;
        base.OnInspectorGUI();
        if (GUILayout.Button("Start Nginx"))
        {
            nginxSettings.StartNginx();
        }
        if (GUILayout.Button("Stop Nginx"))
        {
            nginxSettings.StopNginx();
        }
    }
}

That’s it, now we can create an object, put it in the nginx project and assign nginx.exe as a field in the inspector.

If you are going to pack this into a build, I would do it through StreamingAssets and extending the path detection functionality, but it will still work only on desktop platforms.

It is worth saying that now nginx will not work under Windows outside of your machine, since the Unity editor is blocked in the Windows Firewall for incoming connections. How to set it up correctly can be read here.

We are moving on, now we want to conveniently collect bundles so that they are immediately “poured onto the server”. Let it be local.

Building AssetBundles for Network access

Collecting asset bundles is quite simple. There is a method for this in Unity:

BuildPipeline.BuildAssetBundles(string, BuildAssetBundleOptions, BuildTarget);

Let’s make it a little more convenient by setting a certain set of methods. It will allow us to create certain bundles or everything, assign platforms that we collect, etc.

AssetBundlesBuilder Code

using System.Collections.Generic;
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEngine;

public class AssetBundlesBuilder 
{
    public static void BuildAllAssetBundles(AssetBundlesBuildSettings settings)
    {
        BuildCustomAssetBundles(
            settings.AssetBundleDirectory,
            null,
            settings.Platforms);
    }
    
    public static void BuildSpecifiedAssetBundles(AssetBundlesBuildSettings settings)
    {
        BuildCustomAssetBundles(
            settings.AssetBundleDirectory,
            settings.AssetBundleNamesToBuild,
            settings.Platforms);
    }
    private static void BuildCustomAssetBundles(
        string path,
        string[] assetBundleNames,
        BuildTarget[] platforms)
    {
        if(platforms == null)
        {
            Debug.LogError("Set at least one platform!");
            return;
        };
        
        if (!Directory.Exists(path))
        {
            Directory.CreateDirectory(path);
        }

        var builds = new List<AssetBundleBuild>();
        if (assetBundleNames != null && assetBundleNames.Length != 0)
        {
            assetBundleNames = assetBundleNames.Distinct().ToArray();

            foreach (var assetBundle in assetBundleNames)
            {
                var assetPaths = AssetDatabase.GetAssetPathsFromAssetBundle(assetBundle);
                var build = new AssetBundleBuild
                {
                    assetBundleName = assetBundle,
                    assetNames = assetPaths
                };
                builds.Add(build);
                Debug.Log($"[Asset Bundles] Build bundle: {build.assetBundleName}");
            }
        }
        for (int i = 0; i < platforms.Length; i++)
        {
            var platform = platforms[i];
            BuildAssetBundlesForTarget(path, platform, GetPlatformDirectory(platform),builds.ToArray());
        }
    }
    private static void BuildAssetBundlesForTarget(string path, BuildTarget target, string targetPath, AssetBundleBuild[] bundles = null)
    {
        var directory = Path.Combine(path, targetPath);
        if (!Directory.Exists(directory))
        {
            Directory.CreateDirectory(directory);
        }

        if (bundles == null || bundles.Length == 0)
        {
            BuildPipeline.BuildAssetBundles(directory, BuildAssetBundleOptions.None, target);
        }
        else
        {
            BuildPipeline.BuildAssetBundles(directory, bundles.ToArray(), BuildAssetBundleOptions.None, target);
        }   
    }
    public static string GetPlatformDirectory(BuildTarget target)
    {
        switch (@target)
        {
            default:
                return "standalone";
            case BuildTarget.Android:
                return "android";
            case BuildTarget.iOS:
                return "ios";
            case BuildTarget.StandaloneWindows:
                return "standalone";
            case BuildTarget.StandaloneWindows64:
                return "standalone64";
        }
    }
}

All these methods will be useful to us for our second settings object:

AssetBundlesBuildSettings code

using UnityEditor;
using UnityEngine;

[CreateAssetMenu(fileName = "AssetBundleBuildSettings", menuName = "Asset Bundles/AssetBundleBuildSettings")]
public class AssetBundlesBuildSettings : ScriptableObject
{
    public Object AssetBundleDirectoryObject;
    public string AssetBundleDirectory => AssetDatabase.GetAssetPath(AssetBundleDirectoryObject);
    public BuildTarget[] Platforms;
    public string[] AssetBundleNamesToBuild;
}

AssetBundlesBuildSettingsCustomEditor code

using UnityEditor;
using UnityEngine;
[CustomEditor(typeof(AssetBundlesBuildSettings))]
public class AssetBundlesBuildSettingsCustomEditor : Editor
{
    public override void OnInspectorGUI()
    {
        base.OnInspectorGUI();
        GUILayout.Space(40);
        if (GUILayout.Button("Build All"))
        {
            var settings = target as AssetBundlesBuildSettings;
            AssetBundlesBuilder.BuildAllAssetBundles(settings);
        }
        GUILayout.Space(40);
        if (GUILayout.Button("Build From Names"))
        {
            var settings = target as AssetBundlesBuildSettings;
            AssetBundlesBuilder.BuildSpecifiedAssetBundles(settings);
        }
    }
}

In this example, we will actually pass not an object, but a folder as a UnityEngine.Object, which is also convenient so as not to hardcode paths. I strongly advise using such a mechanism in projects, as it is much more convenient, more flexible and does not require time to recompile the project.

Now it remains to assign the folder for the bundles assembly as the nginx html folder (as in the picture above), and the bundles can be safely downloaded over the local network.

Thanks for your attention! The full repository code can be found here. There is also the client application code. The result of the bundle solution can be quickly tested on different platforms.

Virtual Reality Company | Unity 3d Game Development Company

Go to our cases Get a free quote