Всем привет! Меня зовут Григорий Дядиченко, я занимаюсь продюсированием 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 (как на картинке выше), и бандлы можно спокойно качать по локальной сети.
Спасибо за внимание! Полный код репозитория можно найти тут. Там есть и код клиентского приложения. Результат решения бандлы можно быстро протестировать на разных платформах.