
Привет, меня зовут Артур, и я Unity-разработчик в компании «Альтернатива Гейм».
В мире геймдева часто кажется, что создание карт — это удел художников и левел-дизайнеров: расставил ассеты, настроил свет, и готово.
Но что если ваша игра должна работать на двух абсолютно разных клиентах — устаревшем WebGL 1 и современном Unity для консолей — при этом оставаясь одной и той же игрой? Здесь заканчивается чистое искусство и начинается настоящая программистская магия.
Последние полгода на проекте «Танки Онлайн» мы практически с нуля переписали весь процесс экспорта карт, столкнулись с десятками неочевидных проблем и нашли для них изящные, а порой и винтажные решения, как в старых играх. В этой статье я расскажу, как мы построили инфраструктуру для двух этих миров, и почему иногда проверенные временем техники оказываются надежнее самых современных решений.
Кратко о процессе экспорта

Весь путь карты от идеи до запуска в игре выглядит так:
Создание. Художник собирает карту в Unity, расставляя префабы по сцене.
-
Экспорт. Запускается наш кастомный инструмент, который:
Копирует исходник в отдельную сцену для работы.
Распаковывает префабы.
Запекает свет.
Создает текстурные атласы.
Экспортирует карту для Web-клиента: формирует батчи по диаграмме Вороного, выгружает материалы, коллайдеры, пропы и спец-информацию (точки спавна, килл-зоны и т.д.).
Экспортирует карту для Unity-клиента (Switch, PS4): удаляет штатные компоненты Unity, заменяя их на легковесные кастомные, чтобы бандл со сценой весил меньше.
Да, на клиенте мы тратим время на распаковку, но для игроков на Switch с плохим мобильным интернетом меньший вес скачиваемой карты — приоритет.
С какими проблемами мы сталкиваемся?
Все проблемы на картах можно разделить на два типа:
Проблемы контента. Неправильно настроен материал или рендерер, не стоит нужный флаг, не то число записано, есть модификации в префабе, русская с вместо английской c в названии.
Для их решения мы создали мощный валидатор, который проверяет сцену "вдоль и поперек" и точно указывает на проблемные места.
Проблемы кода. Это была главная головная боль, на которую ушло полгода. Сюда вошли: неправильные или неоптимальные алгоритмы, наличие зависимостей (которые сложно контролировать), сложный код, который потом сложно рефакторить (и нет, это не геймплейный код, это editor-код, где нет никаких абстракций), неправильный результат, который не видно невооруженным глазом, медленный код (не большая проблема для editor-кода, но экспортить карту приходилось очень часто во время тестов, и экономия минуты за прогонку — это очень даже хорошо).
В результате мы сформулировали для художников четкие требования к картам:
Количество треугольников — по возможности < 1 миллиона.
Наличие всех необходимых технических объектов (MapRoot, GameRoot, MapExport, 1 Directional Light Realtime).
Все видимые объекты — инстансы префабов без модификаций (допустимы изменения только в Transform, коллайдерах и, в редких случаях, текстуре).
Запрет на префабы с Apply as override (перезатертые изменения), так как формат для веб клиента не поддерживает произвольные модификации..
Составной префаб не должен содержать ничего, кроме вложенных префабов.
Запрет на повторяющиеся вершины в сабмешах.
У всех префабов должны быть уникальные имена.
Меши можно переиспользовать.
У префаба — не больше 1 меша.
Детальный разбор этапов экспорта

Изначально сцена хранится в исходнике в папке MapScenes, где лежат Unity сцены. Мы копируем ее в другой каталог и дальше работаем с ней, чтобы все изменения не модифицировали исходник.

1. Разъединение сабмешей
Процесс экспорта начинается с первого этапа — разъединение сабмешей на отдельные меши-геймобжекты.
public override void OnClick(MapExport exportData)
{
string meshesSaveDir = BatchObjectsSaver.GetMeshesDir();
EqOrderedDictionary<GameObject, List<GameObject>> dict = BatchObjectsCollector.GetAllPropsForUnpack()
.GroupBy(go => GetCorrespondingObjectFromSource(go))
.ToEqOrderedDictionary(
g => g.Key,
g => g.ToList());
Dictionary<GameObject, GameObject> oldToNewPrefabs = new();
try
{
AssetDatabase.DisallowAutoRefresh();
AssetDatabase.StartAssetEditing();
PathUtils.GetCleanDir(meshesSaveDir);
if (!Directory.Exists(Path.GetFullPath(meshesSaveDir)))
{
Directory.CreateDirectory(Path.GetFullPath(meshesSaveDir));
}
foreach (var keyValuePair in dict)
{
GameObject srcPrefab = keyValuePair.Key;
if (!NeedExtract(srcPrefab))
{
continue;
}
GameObject newPrefab = CreateNewPrefabWithSubMeshes(srcPrefab, meshesSaveDir);
newPrefab.SetActive(false);
oldToNewPrefabs.Add(srcPrefab, newPrefab);
}
foreach (var keyValuePair in dict)
{
for (var i = 0; i < keyValuePair.Value.Count; i++)
{
if (!NeedExtract(keyValuePair.Key))
{
continue;
}
var go = ReplacePrefab(keyValuePair.Value[i].transform, oldToNewPrefabs[keyValuePair.Key]);
go.SetActive(true);
}
}
}
finally
{
AssetDatabase.StopAssetEditing();
AssetDatabase.AllowAutoRefresh();
foreach (GameObject go in oldToNewPrefabs.Values)
{
Object.DestroyImmediate(go);
}
}
}
Наши художники активно используют сабмеши и мультиматериалы. Идея в том, что если пропу нужно несколько разных текстур, в 3д-редакторе для разных частей применяются разные материалы с разными текстурами, при этом модель не разъединяется для них, они продолжают работать с ней одной.
Вся суть в том, что переключение текстуры — это смена рендер стейта, следовательно, составные части такого пропа не могут быть склеены (поскольку имеют мульти материалы). Отсюда следует что эти пропы рисуются разными дроу коллами. Эта проблема решается текстурным атласом. Но в Unity у таких материалов могут в итоге быть разные не только текстуры, но и числовые и векторные свойства, а еще и шейдеры. В таком случае батчинг сильно ломается.
Здесь есть несколько вариантов решения проблемы:
Запретить сабмеши с разными свойствами материалов или шейдерами (должны быть одни свойства и шейдеры в материалах, но у них могут быть разные текстуры).
Запретить сабмеши вообще, заставляя художников разъединять модель в 3D-редакторе (сложно и долго).
Использовать маску с убершейдером (сложно в реализации).
Разъединять сабмеши на отдельные меши автоматически, при экспорте.
Мы решили разъединять сабмеши в отдельные меши в процессе экспорта.
Также нам нужно было сохранять эти меши в ассеты и инстанцировать новые геймобжекты с дочерними объектами с припиской -sub-(номер сабмеша). Тут мы столкнулись с двумя проблемами.
Первая — медленный импорт ассетов. Решили использованием следующего API AssetDatabase: DisallowAutoRefresh и StartAssetEditing.
Вторая — надо сохранять префабы в ассетах. Это гораздо дольше, чем сохранять mesh ассеты, и даже специальное API не сильно ускорило работу.
Но что, если не сохранять префабы, а создавать в воздухе? То есть, мы просто создаем геймобжекты в сцене, которые специально настроены с дочерними сабмешами, копируем их с заменой, а потом удаляем их из сцены. Своего рода "шаблон" объекта.
Таким образом мы решили проблему с сабмешами. Трейдофф в том, что сабмеши шарят между собой вертексы, и отличаются только диапазонами индексов меша, однако подход с разделением субмешей и последующим батчингом дает возможность реиспользовать текстуры на любых объектах без увеличения дроуколлов, поэтому этот плюс сабмешей не важен.
Была также проблема с шаренными вертексами. У смежных сабмешей не должны быть общие вертексы, так как у них 2 разные текстуры с разными положениями в атласе, и второй тайлинг, зашитый в вертекс, перезатрет первый. Столкнувшись с этим, мы быстро внесли новые проверки в валидатор, тем самым решив проблему.
2. Запекание света
Здесь ничего необычного, просто запекаем свет + тени.
public static void BakeLight(MapExport export, bool async, bool replaceToMainFolder)
{
var gameRoot = GameRoot.FindGameRoot();
if (gameRoot != null)
{
gameRoot.gameObject.SetActive(false);
}
RebuildLightmapParameters(export);
bool success;
// clearing reference for Unity not delete LightingData dir on save
if (Lightmapping.lightingDataAsset != null && AssetDatabase.GetAssetPath(Lightmapping.lightingDataAsset).Contains("LD_"))
Lightmapping.lightingDataAsset = null;
LightBakingFunctions.replaceToMainFolder = replaceToMainFolder;
if (async)
{
success = Lightmapping.BakeAsync();
Lightmapping.bakeCompleted -= OnBakeComplete;
Lightmapping.bakeCompleted += OnBakeComplete;
}
else
{
try
{
success = Lightmapping.Bake();
}
finally
{
OnBakeComplete();
}
}
if (!success)
{
throw new Exception("BakeLight failed");
}
}
3. Создание атласов
Раньше мы использовали плагин MeshBaker, но от него отказались в пользу кастомного решения.
MeshBaker справлялся со своей задачей, но работал дольше, чем кастомное решение, которое мы потом написали. Также это лишняя зависимость. Зачем она нужна?
public override void OnClick(MapExport exportData)
{
EqOrderedDictionary<GraphicsFormat, List<Texture2D>> groups = BatchObjectsCollector.GroupBatchedTexturesByFormat();
string scenePath = SceneManager.GetActiveScene().path;
string outDirPath = Path.GetDirectoryName(scenePath);
exportData.atlases.Clear();
EditorUtility.SetDirty(exportData);
int i = 0;
foreach (var pair in groups)
{
TextureFormat format = GetTextureFormat(pair.Key);
Texture2D[] textures = pair.Value.ToArray();
Texture2D atlas;
Rect[] rects = AtlasesBakingFunctions.PackTextures(out atlas, textures, 4, 8192, format);
bool convertToRgb = !format.ToString().Contains("A") && format.ToString().Contains("G");
byte[] bytes = BatchObjectsSaver.EncodeToPng(atlas, convertToRgb);
string resultPath = BatchObjectsSaver.GetAtlasPath(i++, "png");
Debug.Log($"Write file: {resultPath}");
File.WriteAllBytes(PathUtils.ToAbsolutePath(resultPath), bytes);
var list = new List<MapExport.AtlasRect>();
for (int r = 0; r < rects.Length; r++)
{
list.Add(new MapExport.AtlasRect { texture = textures[r], rect = rects[r] });
}
exportData.atlases.Add(new MapExport.Atlas { rects = list.ToArray() });
}
AssetDatabase.Refresh();
}
Так мы создаем атласы. По сути, это обертка над функцией Unity PackAtlases с ручной заливкой паддинга (расстояние между текстурами) для избавления от mip bleeding’а, точнее, чтобы не сильно было его видно.
В атлас попадают только текстуры с Batched рендереров в режиме Clamp. Если объект не помечен как батчащийся — с него не возьмется никакая текстура. Если объект батчащийся, все Clamp текстуры берутся (diffuse и AO текстуры) и запекаются в атласную текстуру. Тайловые текстуры (например, снега) игнорируются.
4. Экспорт для Web-клиента
Формат карты, который используется в Web-клиенте и на сервере — xml. На этом этапе мы конвертируем Unity-сцену в XML, выгружая пропы, коллайдеры, спецобъекты, килл-зоны, и т.д.
Здесь же экспортируются атласные текстуры, тайловые и другие текстуры, а также лайтмапы и файл с моделями в кастомном a3d формате. Так как Web-клиент не поддерживает HDR, мы делаем конвертацию в dLDR формат.
Здесь интересный момент: Unity не позволяет выбрать формат кодирования Лайтмапы самому при выборе Switch платформы движка, поэтому она сохраняется в RGBM. Мы сделали конвертацию вручную.
for (int i = 0; i < lightmapDatas.Length; i++)
{
Texture2D? lightmap = lightmapDatas[i].lightmapColor;
Color[] pixels = lightmap.GetPixels();
float range = 2.0f; // Должно совпадать с CommonLightingModel
for (var ic = 0; ic < pixels.Length; ic++)
{
var color = pixels[ic];
var multiplier = color.a * 5f;
color *= multiplier; // hdr color
var dLdrColor = color;
dLdrColor.r = Mathf.Clamp(dLdrColor.r, 0f, 2f);
dLdrColor.g = Mathf.Clamp(dLdrColor.g, 0f, 2f);
dLdrColor.b = Mathf.Clamp(dLdrColor.b, 0f, 2f);
dLdrColor.a = Mathf.Clamp(dLdrColor.a, 0f, 2f);
dLdrColor *= 0.5f;
pixels[ic] = dLdrColor;
}
5. Финальная подготовка и батчинг
Наконец, экспортировав карту под Web-клиент, остается лишь очистить карту, удалить штатные Unity компоненты с заменой на легковесные (MeshRenderer -> MeshRendererDataComponent), переименовать пропы для легковесности, и очистить всё лишнее. Дальше можно собирать бандл с картой и готово.
public override void OnClick(MapExport mapExport)
{
var groupsByFormat = BatchObjectsCollector.GroupBatchedTexturesByFormat();
var atlasMeterialKeys = BatchObjectsCollector.GroupBatchedRenderersByMaterials(false, true).Keys.ToList();
List<Texture2D> loadAtlases = BatchObjectsSaver.LoadAtlases(true);
Dictionary<Texture2D, Texture2D> textureToAtlas = BatchObjectsCollector.GetTextureToAtlasMap(groupsByFormat, loadAtlases);
foreach (MeshRenderer mr in BatchObjectsCollector.GetAllPropsWithLods())
{
Vector4? uvScaleOffset = FindScaleOffset(mr.sharedMaterial, mapExport.atlases, "_BaseMap");
Vector4? uv2ScaleOffset = FindScaleOffset(mr.sharedMaterial, mapExport.atlases, "_AOTexture");
List<Vector4> uvScaleOffsets = uvScaleOffset.HasValue ? new List<Vector4>() { uvScaleOffset.Value } : new List<Vector4>();
List<Vector4> uv2ScaleOffsets = uv2ScaleOffset.HasValue ? new List<Vector4>() { uv2ScaleOffset.Value } : new List<Vector4>();
var key = MaterialPropsKey.CreateFromMaterial(mr.sharedMaterial, MaterialPropsKey.SettingsForBatching, true, textureToAtlas);
int i = atlasMeterialKeys.IndexOf(key);
if (i >= 0)
{
// replace to atlas material
string materialName = SetAtlasesAsMaterialButton.GetMaterialNameFromKey(key, i);
mr.sharedMaterial = AssetDatabase.LoadAssetAtPath<Material>(BatchObjectsSaver.GetMaterialPath(materialName));
EditorUtility.SetDirty(mr);
}
else
{
if (!BatchObjectsCollector.IsSkipBatch(mr.gameObject))
Debug.LogError("Error with batched object!");
}
mr.gameObject.AddComponent<MeshRendererDataComponent>().SaveDataFromObject(mr, uvScaleOffsets, uv2ScaleOffsets);
Object.DestroyImmediate(mr);
}
}
public class MeshRendererDataComponent : MonoBehaviour
{
private const float fadeTransitionWidth = 0.05f;
private static readonly HashSet<string> treeMeshNames = new() { "beech", "birch" };
public Material[]? sharedMaterials;
[SerializeField] private ShadowCastingMode shadowCastingMode;
[SerializeField] private bool staticShadowCaster;
[SerializeField] private bool receiveShadows;
[SerializeField] private List<Vector4> uvScaleOffset = new List<Vector4>();
[SerializeField] private List<Vector4> uv2ScaleOffset = new List<Vector4>();
//lightmapping
[SerializeField] private int lightmapIndex;
[SerializeField] private Vector4 lightmapScaleOffset;
[SerializeField] private float lodScreenRelativeTransitionHeight;
private List<int> alreadyChangedVertices = new();
private List<int> alreadyChangedVertices2 = new();
private static Vector2[] fixedDisplayUVs = new[]
{
new Vector2(0, 0), new Vector2(1, 1), new Vector2(1, 0),
new Vector2(1, 1), new Vector2(0, 0), new Vector2(0, 1),
new Vector2(0, 0), new Vector2(1, 1), new Vector2(1, 0),
new Vector2(1, 1), new Vector2(0, 0), new Vector2(0, 1),
};
public void SaveDataFromObject(MeshRenderer mr, List<Vector4> uvScaleOffset, List<Vector4> uv2ScaleOffset)
{
receiveShadows = mr.receiveShadows;
shadowCastingMode = mr.shadowCastingMode;
staticShadowCaster = mr.staticShadowCaster;
//lightmapping
lightmapIndex = mr.lightmapIndex;
lightmapScaleOffset = mr.lightmapScaleOffset;
sharedMaterials = mr.sharedMaterials;
if (name.ToLower().Contains("terrain"))
{
}
else
{
this.uvScaleOffset = uvScaleOffset;
this.uv2ScaleOffset = uv2ScaleOffset;
var lodGroup = transform.parent.GetComponent<LODGroup>();
if (lodGroup != null)
{
LOD[] lods = lodGroup.GetLODs();
if (lods.Any(l => l.renderers.Contains(mr)))
{
LOD lod = lods.First(x => x.renderers.Contains(mr));
lodScreenRelativeTransitionHeight = lod.screenRelativeTransitionHeight;
}
}
}
}
// ...
public void ReconstructMeshRenderer()
{
var newMr = gameObject.AddComponent<MeshRenderer>();
Mesh sharedMesh = gameObject.GetComponent<MeshFilter>().sharedMesh;
if (sharedMesh.name.ToLower().Contains("grass"))
{
DestroyImmediate(gameObject);
return;
}
if (sharedMesh.isReadable)
{
if (sharedMesh.name.ToLower().Contains("grass"))
{
DestroyImmediate(gameObject);
return;
}
// ...
}
else
{
Debug.Log($"GameObject's mesh is not readable, skip transform UV: {gameObject}", gameObject);
}
newMr.receiveShadows = receiveShadows;
newMr.shadowCastingMode = shadowCastingMode;
newMr.staticShadowCaster = staticShadowCaster;
//lightmapping
newMr.lightmapIndex = lightmapIndex;
newMr.lightmapScaleOffset = lightmapScaleOffset;
newMr.sharedMaterials = sharedMaterials;
SetLodForTrees(newMr);
ReconstructLodGroup(newMr);
SetStaticFlagForAllChildren(gameObject.transform);
DestroyImmediate(this);
}
}
Распаковка карты:
public void LoadAll()
{
var mapRoot = GetMapRoot();
ApplyGlobalSettings(mapRoot);
Unpack();
// ...
if (FeatureFlags.Instance.UseMapShadersFromBuild)
{
ReplaceShaders(mapRoot);
}
//Сохраняем материал кустов
BattleMapComponent.SetHideableMaterials(mapRoot.bushOrTreesMaterials);
Batch(mapRoot);
SpatialHashGridDebugger.Instance.Init(mapRoot);
SpatialHashGridDebugger.Instance.PrepareGridData(mapBounds.min.x, mapBounds.min.y, mapBounds.min.z, mapBounds.max.x, mapBounds.max.y, mapBounds.max.z);
}
Вот код батчинга карты (в рантайме Юнити клиента):
public static void UseCombinedMeshes(GameObject mapRoot, int cellSize, int mapSize)
{
foreach (var gr in mapRoot.GetComponentsInChildren<MeshFilter>()
.Where(mf => mf.GetComponentInParent<LODGroup>() == null)
.GroupBy(mf => GetBatchKey(mf.GetComponent<MeshRenderer>(), cellSize, mapSize)))
{
CombineInstance[] entries = gr
.SelectMany(mf =>
{
if (mf.sharedMesh.subMeshCount == 1)
{
var combine = new CombineInstance();
combine.mesh = mf.sharedMesh;
combine.transform = mf.transform.localToWorldMatrix;
combine.lightmapScaleOffset = mf.GetComponent<MeshRenderer>().lightmapScaleOffset;
return new [] { combine };
}
else
{
var ar = new CombineInstance[mf.sharedMesh.subMeshCount];
for (int i = 0; i < mf.sharedMesh.subMeshCount; i++)
{
ar[i].mesh = MeshExtension.ExtractSubMesh(mf.sharedMesh, i);
ar[i].transform = mf.transform.localToWorldMatrix;
ar[i].lightmapScaleOffset = mf.GetComponent<MeshRenderer>().lightmapScaleOffset;
}
return ar;
}
})
.ToArray();
if (entries.Length < 2)
continue;
var mesh = new Mesh();
var count = 0;
for (var i = 0; i < entries.Length; i++) {
count += entries[i].mesh.vertexCount;
}
if (count >= 65535) {
mesh.indexFormat = IndexFormat.UInt32;
}
mesh.CombineMeshes(entries, true, true, gr.Key.LightmapIndex >= 0);
mesh.UploadMeshData(true);
var go = new GameObject("MapRootCombined_" + gr.Key);
go.transform.SetParent(mapRoot.transform);
go.AddComponent<MeshFilter>().sharedMesh = mesh;
var addComponent = go.AddComponent<MeshRenderer>();
#if UNITY_EDITOR
UnityEditor.GameObjectUtility.SetStaticEditorFlags(go, UnityEditor.StaticEditorFlags.ContributeGI);
addComponent.receiveGI = ReceiveGI.Lightmaps;
#endif
addComponent.sharedMaterial = gr.Key.Material;
addComponent.lightmapIndex = gr.Key.LightmapIndex;
foreach (MeshFilter mf in gr)
{
DestroyOriginalMeshAndComponents(mf);
}
}
}
Про батчинг
Важный момент: мы не батчим меши заранее. Если это делать, будет большой размер файлов карты, ведь один и тот же меш может попасть в разные батчи. Мы лишь сохраняем данные о батчинге в карте для Web-клиента по карте Вороного или формируем данные для батчинга уже при запуске карты на Unity клиенте (тупо поклеточное разделение карты), а сам процесс создания мешей происходит в рантайме.
Как известно, каждая оптимизация - это трейдофф. Батчинг не исключение. Он прекрасно справляется с задачей уменьшения количества Draw Call’ов, но создает новые проблемы. А проблемы следующие:
Увеличивает GPU time из-за лишней работы вертексного шейдера (даже если видна малая часть меша, он рисуется полностью. Да, пиксельный шейдер не сработает, так как пиксели за пределами экрана клипятся, но вертексный отработает). Также per-object операции дороже (например, перерисовки меша при использовании Projector, forward-рендеринг и прочее).
Работает лишь для статики. (да, есть динамик батчинг, но он работает на мелкой геометрии) Ограничение: с одинаковыми материалами.
Возможно увеличенное потребление памяти
Ломает традиционные LOD'ы
Последнюю проблему мы решили введением HLOD’ов. У нас есть кусты и деревья, которые имеют несколько LOD уровней (я хотел бы, чтобы пропов с лодами у нас было больше :)). Суть техники в том, что если есть несколько разных объектов с одним атласом с лод группами, они склеиваются вместе по-лодно. То есть, создается новый объект с тем же количеством лодов, и каждому его лоду соответствует склеенная группа дочерних лодов. Таким образом несколько LOD0 разных объектов склеиваются в один LOD0 нового объекта, несколько LOD1 в LOD1 и так далее. Здесь также можно было бы применить Instancing, но нужно учитывать его минусы: возможный overdraw из-за сломанного front-to-back порядка при отстутствии препрохода глубины, более долгий куллинг (при gpu driven куллинге, скорее всего, это неважно), ну и тоже ломается из-за лодов.
Почему нам не подходит, например, SRP батчер? Почему не использовать только его? Зачем склеивать геометрию? Он же очень популярен?
SRP батчер отлично справляется, когда у вас много разных материалов и мало разных шейдер вариантов. Благодаря продвинутой GPU-персистентной системе хранения данных материалов, между дроу-коллами не перезаливаются данные материалов, а лишь переключается буфер. Если еще сортировать меши по шейдеру, а не по дистанции (по дистанции сортируют для как можно более быстрого заполнения буфера глубины в случае отсутствия пасса глубины, чтобы early-Z оптимальнее работал), можно попробовать получить еще больший выигрыш благодаря меньшим переключениям шейдера.
Это хорошая оптимизация, и многие скажут вам, что ее вполне достаточно, но тысячи дроуколлов - это тысячи дроуколлов. При том, действительно, эти дроу коллы вызываются без сложных операций друг между другом, при условии одинакового шейдера. Тем не менее, сильно раздробленная геометрия будет вызывать большое количество дроуколлов, и для нас на Switch такое количество неприемлемо.


Тем не менее SRP батчер - хорошая штука, и ее применение со склеиванием геометрии - это не взаимоисключающие вещи.
Да, есть и другие способы уменьшить количество дроу коллов, например Occlusion Culling. Но он не очень подходит для наших открытых карт. К тому же, он тоже не бесплатный (трейдофф).
В современных ААА проектах эта техника склеивания геометрии уже не так распространена. Я даже не уверен, что сейчас текстурные атласы используются где-то за пределами GUI. Зачем, если можно сделать GPU Occlusion Culling и покрыть все сверху DLSS… но статья не просто так называется “техники из 90-ых”. Мы не можем применять все новые технологии, мы должны поддерживать WebGL 1.
6. Генерация ресурса
Вернемся к экспорту карты. Вы думали, что все закончилось? Еще нет, ведь пока что мы имеем данные в сыром виде: PNG’шки, модели и лысый xml.
Следующий этап - генерация ресурса. PNG сжимается в webp (причем с разным уровнем сжатия, контрольная splat текстура террейна жмется без потерь), и также параллельно сжимается для мобилок и Switch ASTC алгоритмом, сохраняется в ktx формате или остается в webp для десктопа, далее сжатые ASTC текстуры и xml сжимают��я gzip’ом и brotli, как и a3d (кастомный формат 3д геометрии) файлы. Туда же кладутся .bundle файлы бандлов Unity под каждую платформу.
Это был последний этап. Теперь мы имеем готовую карту для обоих клиентов.
Вызовы и ограничения (и мысли на будущее)
Мы стараемся держать общность между обоими клиентами.
Да, это сложно, и не везде нужно. Фичи могут работать по-разному, но кардинальной разницы между ними нет. Мы поддерживаем старые платформы с WebGL 1 (GLES 2), и это накладывает существенные ограничения на некоторые механизмы: если что-то есть в WebGL 2 и этого нет в WebGL 1, мы должны реализовать фоллбэк… или не использовать вовсе.
В список не используемых техник попала техника, которая могла бы заменить атласы - текстурные массивы, ее нет в WebGL 1. Текстурные массивы решают проблему mip bleeding’а и жрут меньше памяти, но у них есть ограничение - все текстуры должны в массиве иметь одинаковый размер. Можно сделать несколько массивов на каждый размер текстуры.
Также мы могли бы применить GPU-driven техники. В WebGL это недоступно, ведь нет компьюта. Но компьют шейдеры есть в Unity для консолей.
Я не имею в виду полноценный GPU-driven, в Unity до сих пор нет MDI.
Я не имею в виду использование везде и копирование всей сцены в видеопамять - можно было бы применять это точечно, для куллинга+рисования травы, например.
И я не топлю за полный отказ от традиционного рендеринга - в том же AC: Unity сначала выполняется широкая фаза куллинга по дереву на CPU, а потом более точная - на GPU. Хоть это и приведет к разнице между клиентами (сейчас на Switch не рисуется трава вообще), это не требует изменений на сервере.
Современные решения на движке Unity позволяют самому реализовать frustum culling и формирование дроу коллов. В этом помогают BRG, GRD, кастомный SRP и прочие возможности. Я не могу сказать точно, насколько это полезно в нашем проекте, мы используем URP с кастомными шейдерами, но это определенно дает больший контроль, чем использовать штатные механизмы.
Сжатие текстур в веб-клиенте происходит для мобилок. Десктоп разжатый, он получает текстуры в соответствующем виде.
Заключение
Итак, наш путь от сцены в Unity до работающей карты на двух клиентах завершен. Этот процесс, отлаженный за полгода упорной работы, напоминает мне хорошо собранный механизм из множества шестеренок. Мы не изобретали велосипед заново, а скорее собрали его из проверенных деталей, адаптировав под наши жесткие ограничения.
Поддержка старых платформ, вроде WebGL 1, — это не приговор, а интересный вызов. Он заставляет быть изобретательнее, искать компромиссы и помнить, что не каждая модная техника вроде GPU-driven рендеринга или текстурных массивов может нам помочь. Иногда проще и надежнее использовать метод, проверенный десятилетиями, чем гнаться за ультрасовременными, но недоступными технологиями. Конечно, все зависит от проекта, это тоже важно понимать.
В итоге мы получили стабильный, хоть и сложный, конвейер, который позволяет нашим художникам творить, не думая о технических ограничениях, а игрокам — наслаждаться битвой, независимо от того, играют они на вебе с десяти-двадцатилетним железом или на Nintendo Switch с мобильным интернетом. И лично для меня, как для разработчика, именно такие зад��чи — построение мостов между, казалось бы, несовместимыми мирами — и являются самой интересной частью работы в геймдеве.
Надеюсь, этот разбор был полезен. Спасибо, что дочитали!
Jijiki
жалко что у вас webgl, так бы можно было бы сделать гпу драйвен на gl4.6, а у вас происходит сжатие данных? ведь меши можно сжимать получается, что-то типо
создали меш - карту
посчитали факторы сжатия
записали
.......
открыли-распаковали-данные готовы
(понятно что это не RLE, но может это было бы тоже интересно)
supremestranger Автор
В веб клиенте:
хранятся в a3d в разжатом виде и жмутся brotli
В консольном клиенте:
жмется юнитевым алгоритмом и впоследствии LZ4 как часть бандла