Всем привет! Меня зовут Григорий Дядиченко, я занимаюсь продюсированием digital проектов. Сегодня хотелось бы поговорить про возможности расширения редактора Unity, и как вы можете упростить себе работу на примере включения-выключения nginx из Unity. Мы пройдёмся по теме сборки AssetBundles и работы с процессами в C#.

Я довольно много работаю с виртуальной и дополненной реальностью. Да и в целом с тестированием чего-то на устройствах, где не всё можно сделать в редакторе Unity. И поэтому я задался вопросом: "Как бы упростить себе процесс работы?". Основная машина у меня на Windows, и для сборки билдов на IOS приходится перегонять через shared folder по Wifi сборку на макбук. Можно было бы настроить CI&CD, но с точки зрения итераций это медленнее такого пути. Но помимо существует такая задача, как тестирование контента, где в пересборке билда мало художественного смысла. И можно просто заливать на устройство контент в локальной сети через механизм Asset Bundles. Чтож, ну, а тут как не потратить пол дня на такую задачку?

Но чтобы бандлы доставлять нужен веб-сервер. Сначала я попробовал сделать это с помощью сервера из этой статьи, но он слишком топорный, и там будет тяжело поддержать gzip. Поэтому в голову сразу пришёл nginx. Но так как хочется сделать и себе, и людям, то надо бы упростить запуск nginx для Unity разработчиков, и ещё чтобы он выключался с выключением редактора и в целом из редактора им управлять (ну в рамках нашей задачи). Чтож, решение придумано - пора делать.

Интеграция NGINX в Unity проект

В целом навык запускать из редактора всякие консольные утилиты или exe файлы - штука довольно полезная. Таким образом можно быстро без сложной разработки расширять возможности редактора, но помимо редактора, если проект под десктопную платформу навык работать с классом Process, позволяет вам так же обращаться к нужным утилитам уже в билде. Мы же разберём случай с редактором. Чтож, напишем класс для запуска нашего nginx.

Для начала напишем просто запуск процесса и разберём, что он делает:

Код запуска процесса
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}");
}

В данном блоке кода мы:
1. Создаём новый процесс
2. Задаём параметры запуска. Из интересного:
startInfo.WindowStyle = ProcessWindowStyle.Hidden; — чтобы у нас не появлялась консоль.
startInfo.WorkingDirectory = path; некоторые процессы зависят от рабочей директории.
startInfo.CreateNoWindow = true; — нужно ли запускать процесс в новом окне.
3. Запускаем процесс

Запуск процесса написан, теперь нам нужен путь до исполняемого файла. Можно конечно его захардкодить, но в Unity есть инструмент в разы удобнее. Дело в том, что UnityEngine.Object задать в качестве поля в инспекторе, и при этом все файлы и папки проекта являются наследником UnityEngine.Object. Дальше через AssetDatabase.GetAssetPath можно получить полный путь до объекта. И использовать его для наших целей. Вот полный код нашего объекта настройки nginx:

Код NGINXSettings
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}");
    }
    
}

В данном случае для удобства работы в редакторе мы сделаем Scriptable Object с кастомным инспектором. Более подробно SO и кастомные инспекторы я разбирал тут. Правда тогда я делал чуть иначе, поэтому тут стоит сказать, что делает аттрибут CreateAssetMenu. Он позволяет вам создавать SO по правой кнопке мыши в редакторе.

А вот кастомные инспекторы там разобраны неплохо, поэтому следущий код примем за данность, чтобы у нас появились кнопки. Тем более он элементарный:

Код NGINXSettingsCustomEditor
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();
        }
    }
}

Всё, теперь мы можем создать объект, положить в проект nginx и назначить nginx.exe в качестве поля в инспекторе.

Если вы собираетесь это паковать в билд, я бы делал это через StreamingAssets и расширение функциональности определения пути, но работать это всё равно будет только на десктопных платформах.

Стоит сказать, что сейчас у вас nginx работать не будет под Windows за пределами вашей машины, так как Unity редактор заблокирован в Windows Firewall на входящие соединения. Как это настроить правильно можно прочитать тут.

Едем дальше, теперь нам хочется удобно собирать бандлы, чтобы они сразу "заливались на сервер". Пусть и локальный.

Сборка AssetBundles для доступа по сети

Собирать ассет бандлы довольно просто. Для этого в Юнити есть метод:

BuildPipeline.BuildAssetBundles(string, BuildAssetBundleOptions, BuildTarget);

Сделаем это чуть удобнее, задав некоторый набор методов. Он будет позволять нам создавать определённые бандлы или все, назначать платформы которые мы собираем и т.п.

Код AssetBundlesBuilder
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";
        }
    }
}

Все эти методы нам пригодятся для нашего второго объекта настроек:

Код AssetBundlesBuildSettings
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
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);
        }
    }
}

В этом примере мы собственно будем передавать не объект, а папку в качестве UnityEngine.Object, что так же удобно, чтобы не хардкодить пути. Очень советую использовать такой механизм в проектах, так как это в разы удобнее, гибче и не требует времени на рекомпиляцию проекта.

Теперь осталось назначить папку для сборки bundles в качестве html папки nginx (как на картинке выше), и бандлы можно спокойно качать по локальной сети.

Спасибо за внимание! Полный код репозитория можно найти тут. Там есть и код клиентского приложения. Результат решения бандлы можно быстро протестировать на разных платформах.

Комментарии (0)