Pull to refresh
0
Lightmap
Разработчик мобильных игр

Как за день потерять 30% онлайна и за два месяца сделать батлрояль

Reading time18 min
Views27K

Однажды в своем PvP-шутере мы за один день потеряли 30% от 60 тысяч одновременного онлайна. Это был 2018-й, в тот день на iOS вышел Fortnite. И хотя хайп был уже около года, а PUBG регулярно бил рекорды, без королевской битвы нам жилось вполне спокойно. Но тут стало очевидно, что батлрояль нельзя игнорировать, игрокам он нравится и нужно разрабатывать свой.

За два месяца мы проделали много интересной и сложной работы. Например, сделали свой редактор террейна, чтобы отрисовывать ландшафт не в 500 тысяч треугольников, как это делал Unity Terrain, а всего в 30 тысяч. Или написали мешбейкер, сократив количество запросов на отрисовку с 800 до 100, а освещение научились запекать за 5 минут вместо 30 часов. 

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

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

Тем не менее, трудностей было много: отсутствие нужных плагинов Unity, низкая производительность мобильных устройств, проблемы с оперативной памятью и так далее. Забегая вперед — со всем справились, правда, для этого пришлось написать пачку собственных инструментов.

Батлрояль был полностью готов за два месяца. Тайминги примерно такие:

  • 2 недели — переносили лейауты и объекты из существующих карт, создавали ландшафт в Unity Terrain, писали сетевой и геймплейный код;

  • 2 недели — писали собственный редактор террейнов, мешбейкер, систему пулов, анализатор, правили баги в коде;

  • 1 месяц — оптимизировали сетевую часть и занимались полировкой созданных инструментов, наводили лоск на карте.

Если разделить всю работу на условные части, то получится три больших блока: графика, сеть и геймдизайн. Все и сразу не рассказать, поэтому сейчас подробно остановимся на графической части и создании карты.

Террейн и пропсы 

В Pixel Gun 3D раньше никогда не было карт крупнее, чем 200 на 200 метров. А теперь нам предстояло сделать локацию в 100 раз больше — огромный остров 2 на 2 километра, над которым при этом летят игроки и видят его целиком.

В игре уже есть несколько десятков мультиплеерных карт и локаций из одиночной кампании, которые хорошо знакомы игрокам. Поэтому решили, что батлрояль должен стать оммажем ко всей остальной игре, даже к сингловой ее части. Так игрокам было бы намного проще освоиться в новом режиме, ориентироваться на карте и давать друг другу информацию. А для себя мы таким образом решили проблемы с разработкой лейаутов — зачем изобретать велосипед, когда в проекте больше 100 отточенных временем сетевых карт.

Для создания ландшафта выбрали редактор Unity Terrain, он был прост и понятен левел-дизайнеру и позволял быстро прототипировать. Для ускорения разработки ассеты были взяты из уже существующих локаций игры. Но у всех объектов уже были свои отдельные текстуры и материалы — это первая из возникших проблем. 

Чтобы отрисовать объект на экране, Unity отправляет команду Draw Call (запрос на отрисовку) — чем больше объектов с уникальными материалами, тем больше запросов и тем сильнее нагрузка на CPU и GPU. Если есть объекты с одинаковым материалом и они соответствуют требованиям, то процессор может отправить запрос на отрисовку нескольких объектов одной пачкой (батчинг). В нашем случае у всех объектов были разные материалы, поэтому даже без полной отрисовки карты получили в районе 800 запросов, что очень много. В мобильном проекте идеально держать около 100 дроуколов. 

Для решения этой проблемы нужно, чтобы все графические ассеты использовали один материал и один текстурный атлас. Это можно было сделать, выдав соответствующую задачу арт-отделу, но ее решение в пакете 3D-моделирования заняло бы не один десяток человеко-часов. Да и, в конце концов, мы не в каменном веке — решили автоматизировать процесс. 

Можно было использовать плагин Mesh Baker, но с ним бы возникли неудобства, так как «из коробки» плагин не предоставлял весь необходимый инструментарий ввиду уникальности наших запросов. Все, чем он мог помочь — запечь кучу разрозненных материалов и текстур в один атлас. В итоге, легче было написать собственный инструмент, чем разбираться со сторонним.

Код для создания текстурного атласа и смены текстурных координат для мешей:
// при помощи текстур пакера создаем атлас
private void BakeTexture()
	{
		newTexture = new Texture2D(8, 8, TextureFormat.ETC_RGB4, false);
		var Rects = newTexture.PackTextures(textures.ToArray(), 0, 2048, false);
		for (int i = 0; i < Rects.Length; i++)
		{
			AtlasTextures[i].rect = Rects[i];
		}
		newTexture.Apply();
	}

// меняем текстурные координаты меша при помощи данных из текстур пакера
private void BakeMesh(Mesh meshInstance, int meshId)
	{
		tempMeshes.Add(new Mesh());
		var newMesh = tempMeshes[meshId];
		newMesh.vertices = meshInstance.vertices;
		newMesh.normals = meshInstance.normals;
		//код для объектов с подкраской при помощи vertex color
		if (meshInstance.colors != null && meshInstance.colors.Length != 0)
		{
			newMesh.colors = meshInstance.colors;
		}

		for (int i = 0; i < newMesh.subMeshCount; i++)
		{
			newMesh.SetIndices(meshInstance.GetIndices(i), meshInstance.GetTopology(i), i);
			newMesh.SetTriangles(meshInstance.GetTriangles(i), i);
		}

		var rect = AtlasTextures[meshId].rect;

		Vector2 ofst = new Vector2(rect.xMin, rect.yMin);
		Vector2 scl = new Vector2(rect.width, rect.height);

		Vector2[] newUv = new Vector2[meshInstance.uv.Length];

		for (int i = 0; i < meshInstance.uv.Length; i++)
		{
			Vector2 uv = meshInstance.uv[i];
			newUv[i] = Vector2.Scale(uv, scl) + ofst;
		}

		newMesh.uv = newUv;

		if (meshInstance.uv2 != null && meshInstance.uv2.Length != 0)
		{
		newMesh.uv2 = meshInstance.uv2;
		}

		tempMeshes[meshId] = newMesh;
	}

Как работает наш собственный мешбейкер: мы выдаем ему список префабов для запекания в атлас, указываем, как назвать атлас и материал. После чего по нажатию кнопки calculate создается специальный каталог для запеченных объектов и туда сохраняются автоматически перенастроенные префабы, а в еще одну папку — новый меш, атлас и материал. В итоге сохраняются исходные ассеты и появляются новые, уже оптимизированные.

Объекты сгруппировали по типам: с прозрачностью или без, с подкраской по вертексами или без и так далее. На выходе из множества материалов мы получили шесть штук и единый текстурный атлас. 

Маленький лайфхак: необязательно хранить меши как .obj  или .fbx, Unity умеет их сохранять как .asset.  

Оригинальный меш с отдельным материалом и текстурой
Оригинальный меш с отдельным материалом и текстурой
Новый меш, уже замаплен на атлас и хранится как .asset
Новый меш, уже замаплен на атлас и хранится как .asset
Готовый текстурный атлас
Готовый текстурный атлас

Количество отрисовок до четырех, конечно, не сократилось, потому что на карте остались объекты с тайловыми текстурами. Например, дом состоит из одного меша и на нем висит 4 материала: плитка, шифер, кирпич и дерево. Тайловые текстуры невозможно запечь в атлас, так что оставили как есть (на самом деле возможно, если очень сильно захотеть, но тут напрашивается отдельная статья). Тем не менее, количество запросов на отрисовку с 800 мы срезали до 100-120. Уже лучше.

Другая проблема возникла с Unity Terrain — он требовал на отрисовку в среднем 500 тысяч треугольников. Для сравнения в режиме тимфайт со всеми интерфейсами, партиклами и остальным был максимум в 150 тысяч трисов на отрисовку самого «прожорливого» кадра. А тут 500 тысяч только на террейн.

Решили, что когда левел-дизайнер закончит работу с террейном, его меш будет перемоделен и оптимизирован в 3ds Max. Но от этого быстро отказались, так как лишались гибкости при дальнейшей работе с картой: левел-дизайнеру пришлось бы все изменения в ландшафте делать при помощи рук 3D-артистов, а им в свою очередь постоянно перемоделивать и изменять меш карты. В общем, редактор террейна был нужен, но Unity Terrain нам не подходил.

Стали ресерчить плагины в Asset Store и что они вообще умеют. Большинство из них изобиловали лишними возможностями, которые ухудшали итоговую производительность. Нам же требовалось максимально легковесное решение.

Решили написать собственный редактор террейна, чтобы полностью все контролировать. За основу в нем взяли систему чанков. В нашем случае чанки — это квадраты 200 на 200 метров, на которые разбивается вся карта батлрояля. Всего 100 штук. 

Например, игрок находится в определенном чанке:

  1. текущий чанк отрисовывается в максимальном разрешении; 

  2. близлежащие соседние чанки — тоже в максимальном разрешении;

  3. следующий ряд за ними — в половинчатом разрешении; 

  4. все, что дальше — в 4 раза меньше.

Чанки
Чанки

С такой системой террейн стал отрисовываться не в 500 тысяч треугольников, а всего в 30 тысяч. Для его покраски использовали технологию Texture Splating и написали собственный легковесный шейдер.

Так как террейн изначально был создан в Unity Terrain, то его предстояло перенести на собственный редактор. Написали расширение, которое позволило нашему редактору работать с картой высот из Unity Terrain.

В результате написали генератор террейна, редактор для дизайнеров с системой покраски, а также провели работу по профилированию и оптимизации.

Редактор для дизайнеров
Редактор для дизайнеров

Что под капотом нашего домашнего редактора террейна? Позиции вершин и UV-координаты для меша террейна генерируются в риалтайме, а хранится только список высот. Их мы храним как byte (значение от 0 до 255), так как 255 градаций высоты для нашей карты вполне достаточно.

Наш террейн имеет три градации детализации: низкополигональный меш хранится на сцене, меши средней и высокой детализации генерируются в рантайме. Чтобы не было утечек памяти, мид и хай меши создаются только 1 раз на запуске сцены, после чего в них просто подменяются данные о положении вертексов и UV-координат. В редакторе террейна дизайнеры работают только с хай мешем, по нажатию кнопки «сохранить» заполняется список высот и генерируется лоу меш, который автоматически размещается на сцене королевской битвы.

Код генерации/перерасчета меша для чанка:
        private Vector3[] verts; // список вертексов аллоцируем один раз и дальше просто перезаполняем
        private Vector2[] uv; // координаты развертки также аллоцируем единожды
// decimator служит для определения разрешения меша
        private void UpdateMesh(int X, int Y, ref MeshInstance TerrainMeshPart, bool isEdge = false, MeshPosition meshPosition = MeshPosition.Center, int decimator = 1)
        {
            int lowMeshIndex = ArrayToLinear(X, Y, terrainData.gridWidth);
        
            TerrainMeshPart.meshRenderer.enabled = true;
            TerrainMeshPart.LowMesh = lowMesh[lowMeshIndex];

            bool FirstTimeMeshCreated = (TerrainMeshPart.mesh == null || TerrainMeshPart.mesh.vertexCount == 0); // проверяем новый меш или перезаполняем список вертексов и UV-координат

            if (TerrainMeshPart.LowMesh != null)
            {
                TerrainMeshPart.LowMesh.enabled = false;
                TerrainMeshPart.meshRenderer.sharedMaterial = TerrainMeshPart.usePreviewMaterial ? terrainData.previewTerrainMaterial : terrainData.materials[lowMeshIndex];
                TerrainMeshPart.trans.position = TerrainMeshPart.LowMesh.transform.position;
            }
        
            int meshResolution = terrainData.maxMeshResolution / decimator + 1;
            int partWidth = (terrainData.width -1) / terrainData.gridWidth;
            float partSize = terrainData.realSize / terrainData.gridWidth;

            int xCoord;
            int yCoord;
            float height;

            bool isTop = meshPosition == MeshPosition.Top || meshPosition == MeshPosition.TopLeft || meshPosition == MeshPosition.TopRight;
            bool isBot = meshPosition == MeshPosition.Bot || meshPosition == MeshPosition.BotLeft || meshPosition == MeshPosition.BotRight;
            bool isLeft = meshPosition == MeshPosition.Left || meshPosition == MeshPosition.TopLeft || meshPosition == MeshPosition.BotLeft;
            bool isRight = meshPosition == MeshPosition.Right || meshPosition == MeshPosition.TopRight || meshPosition == MeshPosition.BotRight; // чтобы избежать дырок на стыках лоу и хай меша, определяем с каких граней чанка нужно дополнительно обработать позиции четных вертексов

            float ReadHeightStep = (float)partWidth / terrainData.maxMeshResolution * decimator;
            int intReadHeightStep = Mathf.RoundToInt(ReadHeightStep);
            float VertexStep = terrainData.realSize / (float)terrainData.gridWidth / terrainData.maxMeshResolution * decimator;

            float xVertexPos = 0;
            float yVertexPos = 0;

            int ReadCoordinateForX = 0;
            int ReadCoordinateForY = 0;

            float fromByteToFloat = 1f / 255;
            float finalHeightMultiplyer = terrainData.MaxHeight * fromByteToFloat;
        
            for (int x = 0; x < meshResolution; x++)
            {
                for (int y = 0; y < meshResolution; y++)
                {
                    int offsetX = partWidth * X;
                    int offsetY = partWidth * Y;

                    if (isEdge && (x & 1) != 0 && ((y == 0 && isBot) || (y == meshResolution - 1 && isTop)))
                    {
                        xCoord = offsetX + (x - 1) * intReadHeightStep;
                        ReadCoordinateForX = offsetX + (x + 1) * intReadHeightStep;

                        yCoord = offsetY + y * intReadHeightStep;

                        byte heightT = heights[ArrayToLinear(ReadCoordinateForX, yCoord, terrainData.minitex.width)];
                        height = 0.5f * (heights[ArrayToLinear(xCoord, yCoord, terrainData.minitex.width)] + heightT);
                    }
                    else if (isEdge && (y & 1) != 0 && ((x == 0 && isLeft) || (x == meshResolution - 1 && isRight)))
                    {
                        xCoord = offsetX + x * intReadHeightStep;

                        ReadCoordinateForY = offsetY + (y + 1) * intReadHeightStep;
                        yCoord = offsetY + (y - 1) * intReadHeightStep;

                        byte heightT = heights[ArrayToLinear(xCoord, ReadCoordinateForY, terrainData.minitex.width)];
                        height = 0.5f * (heights[ArrayToLinear(xCoord, yCoord, terrainData.minitex.width)] + heightT);
                    }
                    else
                    {
                        xCoord = offsetX + x * intReadHeightStep;
                        yCoord = offsetY + y * intReadHeightStep;

                        int index = ArrayToLinear(xCoord, yCoord, terrainData.minitex.width);
                    
                        height = heights[index];
                    }

                    int arrPos = ArrayToLinear(x, y, meshResolution);

                    xVertexPos = x * VertexStep;
                    yVertexPos = y * VertexStep;

                    verts[arrPos] = new Vector3(xVertexPos, height * finalHeightMultiplyer, yVertexPos);
                    uv[arrPos] = new Vector2((partSize * X + xVertexPos) / terrainData.realSize, (partSize * Y + yVertexPos) / terrainData.realSize);

                    if (FirstTimeMeshCreated)//check if triangles already created
                    {
                        if (x < meshResolution - 1 && y < meshResolution - 1)
                        {
                            indicesList.Add(arrPos);
                            indicesList.Add(ArrayToLinear(x, y + 1, meshResolution));
                            indicesList.Add(ArrayToLinear(x + 1, y + 1, meshResolution));

                            indicesList.Add(arrPos);
                            indicesList.Add(ArrayToLinear(x + 1, y + 1, meshResolution));
                            indicesList.Add(ArrayToLinear(x + 1, y, meshResolution));
                        }
                    }
                }
            }
        
            TerrainMeshPart.mesh.vertices = verts;
            TerrainMeshPart.mesh.uv = uv;

            if (!FirstTimeMeshCreated) return;
            
            float volume = terrainData.realSize / terrainData.gridWidth;
            var BoundsSize = new Vector3(volume, terrainData.MaxHeight, volume);
            TerrainMeshPart.mesh.bounds = new Bounds(BoundsSize / 2, BoundsSize);
            TerrainMeshPart.mesh.SetIndices(indicesList.ToArray(), MeshTopology.Triangles, 0);
        }

Лодирование

Дальше нужно было оптимизировать объекты на карте. Для их отрисовки на дистанции использовали систему лодов — чем дальше объект, тем меньше его детализация. Казалось, инструмент «из коробки» должен просто работать, но на деле — нет. На карте оказалось несколько десятков тысяч объектов: ящики, кусты, заборы, дома, деревья. 

С системой лодов есть особенность: в ней настраивается не дистанция, с которой будет виден объект, а соотношение размера габаритного бокса объекта к высоте экрана, после которого объект переключит свой уровень детализации. Например, если на одной дистанции стоит стул и дерево, то при одинаковых настройках стул будет раньше дерева переключать свои лоды.

Область видимости у нас ограничена туманом, который полностью скрывает объекты, начиная с расстояния 150 метров. Какие-то объекты при одинаковых настройках меняются, а какие-то не меняются даже в тумане (когда их можно отрисовать попроще). Можно было бы настроить все вручную, но бедных дизайнеров и тут спасла автоматизация. 

Написали специальный скрипт, который пересчитывал размер лодов под дистанции. Настройка у лодов от 0 до 1, где нужно ставить границу: например, от 0 до 0,3, от 0,3 до 0,5 и так далее. Мы это перевели в метры и получили для каждого объекта индивидуальные настройки.

Нехитрый кусок кода для перевода дистанции в относительные размеры на экране:
float DistanceToRelativeHeight(float distance, float lodHeight)
    {
        var halfAngle = Mathf.Tan(Mathf.Deg2Rad * defaultFov * 0.5f);
        var relativeHeight = lodHeight * 0.5f / (distance * halfAngle);
        return relativeHeight;
    }

Важно: результат вычислений зависит от угла обзора камеры, поэтому все расчеты проводились под наш таргетный FOV.

Кроме того, если LODGroup не отрисовывает объект (отсекает по дистанции), CPU все равно производит вычисления, потому что даже невидимый лод каждый кадр высчитывает, нужно ему отображаться или нет.

Чтобы снять нагрузку, решили скрывать лоды в удаленных от игрока чанках. Это значительно увеличило производительность и выявило новую проблему — оперативная память была забита.

На карте несколько десятков тысяч объектов и все они занимают оперативную память. Тонна сериализуемых данных, имена объектов, информация об их положении, повороте, скейле и так далее. В рамках одного объекта эта информация весит сущий пустяк, но когда их очень много — начинаются проблемы. Весь объем данных об объектах на карте съедал под 200 МБ оперативной памяти, что приводило к вылетам по нехватке памяти на слабых девайсах. Две сотни мегабайт под трансформы, компоненты и прочие вещи, когда нужно хранить еще и информацию о текстурах, мешах, анимациях, конфигах и так далее — непозволительная роскошь.

Система пулов

Решили кардинально закрыть вопрос с оперативной памятью и написали систему пулов. Она считает максимальное количество объектов, которые можно одновременно отрисовать. Например, максимальное количество деревьев, которое игрок может увидеть за раз равно 20 — мы помещаем 20 деревьев в пул и расставляем по карте только там, где они должны быть видны в данный момент. Расстановка объектов происходит не каждый кадр, а при переходе из чанка в чанк.

Система помнит, где и какой объект стоит, поэтому не хранит лишнюю информацию. Раньше у нас стоял реальный объект с кучей скриптов и компонентов Unity, а сейчас об объекте хранится только самое важное: где взять его экземпляр, куда поставить и как повернуть.

В пул решили не помещать большие объекты, служащие лендмарками, которые должны быть видны издалека. Лодирование для них настраивалось вручную.

Код расстановки объектов для чанка:
public void PlaceObjects(int x, int y)
	{
		newChunks = FillListOfAjacentChunks(x,y);
  	// неиспользуемыми считаются чанки, которых нет в списке newChunks
		ClearUnusedChunks();

		for (int i = 0; i < newChunks.Count; i++)
		{
			var newChunkId = newChunks[i];                          
			// если чанк есть в списке currentChunks, то объекты на нем уже расставлены
			// и расставлять их заново не нужно
			if (newChunkId != -1 && !currentChunks.Contains(newChunkId))
			{
        UpdateChunk(newChunkId);
      }
    }
  
  	for (int i = 0; i < newChunks.Count; i++)
    {
      var newChunkId = newChunks[i];
      currentChunks[i] = newChunkId;
    }
	}

// тут мы используем сериализуемые Dictionary для хранения списка списков
	#region MyDictionaryOfPoolObjects
	[System.Serializable]
	public class MyDictionaryOfPoolObjects : SerializableDictionary<string, PoolObjListHolder> { }

	[System.Serializable]
	public class PoolObjListHolder
	{
		public List<PoolObj> entityList;
	}
	#endregion


void UpdateChunk(int chunkID)
	{
		Chunk chunk = chunks[chunkID];
		for (int i = 0; i < chunk.objectsInChunk.Count; i++)
		{
			string key = chunk.objectsInChunk[i];
			List<PoolObj> _pool = pool.dictionary[key].entityList;
			int _placedObjectsCount = poolUseCounter.dictionary[key];
			List<Vector3> poses = chunk.positions.dictionary[key].entityList;
      List<Quaternion> rotations = chunk.rotations.dictionary[key].entityList;
				// учитываем количество свободных объектов
				// из количества объектов в пуле вычитаем число уже используемых
				// а также вычитаем количество которое предстоит расставить
			int delta = _pool.Count - _placedObjectsCount - poses.Count;
				// если объектов нужно больше, чем лежит в пуле, то создаем еще
			if (delta < 0)
			{
				ExpandPool(_pool, Mathf.Abs(delta) + 1);
      }                      
				// так как мы не знаем наверняка, какие объекты уже используются, а какие возвращены в пул, то пройдемся по всем начиная с первого
			int counter = 0;
			for (int j = 0; j < poses.Count; j++)
			{
				var poolObj = _pool[counter];
				counter++;
				// если объект уже на карте, то пропускаем его
				if (poolObj.isPlaced)
				{
					j--;
					continue;
        }
				_placedObjectsCount++;
        poolObj.currentChunk = chunkID;
        poolObj.isPlaced = true;
        poolObj.obj.position = poses[j];
        poolObj.obj.rotation = rotations[j];
      }
      poolUseCounter.dictionary[key] = _placedObjectsCount;
    }
  }

Если раньше все объекты одновременно стояли на карте и забивали оперативку кучей информации, то теперь их поместили в пул. Условно, в памяти стало хранится не 2000 деревьев, а 20. 

Как мы определяем сколько объектов поместить в пул? Пул проходится по чанкам и проверяет, какое максимальное количество объектов может отрисовываться за раз. Например, находясь в чанке х2y2, объекты будут расставлены для него и всех соседних чанков. Так мы проходимся по каждому чанку, захватывая соседние, рассчитываем максимальное количество активных объектов одного типа и запоминаем его, если число больше предыдущего.

Код подсчета количества экземпляров уникального объекта для помещения в пул:
List<int> MaxUniqueObjectInstancesCount = new List<int>();

	for (int i = 0; i < UniqueObjectNames.Count; i++)
		{
			if (isDebug)
      {
        Debug.Log("Prefab name : " + UniqueObjectNames[i]);
      }
    MaxUniqueObjectInstancesCount.Add(0);
    for (int x = 0; x < Grid; x++)
    {
      for (int y = 0; y < Grid; y++)
      {
        var maxCountOfObjectsForChunk = CalculateMaxObjectsInThisAndAdjacentChunks(x, y, UniqueObjectNames[i]);
        if (isDebug && maxCountOfObjectsForChunk > 0)
        {
          Debug.Log(string.Format("Chunk: ({0},{1}) Count : {2}", x, y, maxCountOfObjectsForChunk));
        }
        if (maxCountOfObjectsForChunk > MaxUniqueObjectInstancesCount[i])
        {          MaxUniqueObjectInstancesCount[i] = maxCountOfObjectsForChunk;
        }
      }
    }
  }

Объекты пула хранятся в удаленных координатах (х -999999) в активном состоянии, неактивны только компоненты объектов (Renderer, MeshFilter, LODGroup, MeshCollider и другие). При размещении объекта на карту все компоненты включаются. 

Почему просто не включать/выключать родительский объект? Во время профилирования обратили внимание, что при одновременном включении кучи объектов происходит резкий дроп производительности из-за массового вызова GameObject.Activate (Unity выполняет метод Activate, чтобы вызвать на всех компонентах включенного объекта OnEnable и произвести прочие действия).

С пулом была одна особенность: он работал только с инстансами префабов, так пул-менеджер определял является ли объект часто повторяющимся. Например, левел-дизайнер случайно ломал связь инстанса с основным префабом или еще как-то менял его — и тот переставал подтягиваться в пул. Или (что еще хуже) менял объект, когда тот был уже в пуле. С этим тоже был отдельный блок работы, чтобы все подобные ситуации правильно читались системой и все работало как надо.

Чтобы определить потенциальную нагрузку на GPU внутри чанка, мы написали анализатор. Он проходится по каждому чанку и сообщает, сколько в нем объектов находится и сколько треугольников нужно на отрисовку всех объектов. Отличительной чертой всех объектов на карте является наличие компонента LODGroup, нам осталось только получить их список и подсчитать суммарное количество треугольников по чанкам. 

Код можно было улучшить, но тогда мы были сильно ограничены во времени:
    [ContextMenu("(Debug)Find chunks triangles density")]
    void CheckVertexCount()
    {
        PlaceAllObjects();

        Dictionary<string, int> chunkTrianglesCount = new Dictionary<string, int>();
        var worldObjects = FindObjectsOfType<LODGroup>();
        for (int i = 0; i < worldObjects.Length; i++)
        {
            int trianglesCount = 0;
            var chunkId = chunks[GetCurrentChunkId(worldObjects[i].transform.position)].id.ToString();
            if (chunkTrianglesCount.ContainsKey(chunkId)) trianglesCount = chunkTrianglesCount[chunkId];
            else chunkTrianglesCount.Add(chunkId, 0);
            if (worldObjects[i].GetLODs()[0].renderers != null)
            {
                var count = 0;
                foreach (Renderer r in worldObjects[i].GetLODs()[0].renderers)
                {
                    if (r)
                    {
                        MeshFilter meshFilter = r.GetComponent<MeshFilter>();
                        if (meshFilter && meshFilter.sharedMesh != null)
                        {
                            count += meshFilter.sharedMesh.triangles.Length;
                        }
                    }
                }
                trianglesCount += count;
                // логируем количество треугольников в объектах
                Debug.Log(chunkId + " Object Name: " + worldObjects[i].name + " Triangles Count: " + count);
            }
            chunkTrianglesCount[chunkId] = trianglesCount;
        }

        for (int i = 0; i < chunks.Length; i++)
        {
            var chunkId = chunks[i].id.ToString();
            if (chunkTrianglesCount.ContainsKey(chunkId))
                Debug.Log("Chunk: " + chunkId + " Triangles count: " + chunkTrianglesCount[chunkId]);
        }

        RemoveAllObjectsToPool();
    }

Получив такую статистику, нашли насыщенные объектами места. И уже левел-дизайнеры и художники взялись за оптимизацию отдельных участков. Например, в игре есть район со школой, где анализатор насчитал 300 тысяч вертексов. Убрали лишний декор типа глобусов на столах, раскиданных книг, геймплейно ненужных камней и кустов — количество сократилось до 100 тысяч.

Освещение

Следующим этапом нужно было запекать освещение. На предыдущем проекте мы писали отдельную технологию подгрузки карт освещения, но она работала медленно и много весила — на самой мощной из доступных нам машин расчет освещения для всей карты занимал от 10 до 30 часов. Такой вариант точно не подходил.

К тому же, это не работало с нашей текущей реализацией системы пула — чтобы запечь свет, системе освещения нужно знать точные позиции объектов. То есть нужно, чтобы все условные деревья стояли на своих местах, от чего мы буквально только что отказались.

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

Карта теней
Карта теней

В итоге вместо 30 часов на запекание теней уходит 5 минут. Переставили объекты, нажали одну кнопку и получили готовую карту теней.

Тут есть важный момент: тени падают на сам террейн, но не на объекты (на них не работает лайтмаппинг). Для решения этой задачи написали шейдер, который берет нашу карту теней и накладывает на объекты, используя координаты их вертексов.

Код шейдера:
Shader "Optimized/FallBack/DiffuseTopDownShaded"
{
    Properties
    {
        _MainTex("Texture", 2D) = "white" {}
        _ShadowTex("Shadow Texture", 2D) = "white" {}
        _Scale("Scale", Float) = 1
        _OffsetX("Offset X", Float) = 1
        _OffsetY("Offset Y", Float) = 1
    }
        SubShader
    {
        Tags{ "RenderType" = "Opaque" "Queue" = "Geometry"  "IgnoreProjector" = "True" }
        ZWrite On
        ZTest Less
        LOD 200
        Cull Back
        Lighting Off

        Pass
    {
        CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma multi_compile_fog

#include "UnityCG.cginc"

    struct v2f
    {
        float2 uv : TEXCOORD0;
        fixed2 projectedUV : TEXCOORD2;
        UNITY_FOG_COORDS(1)
        float4 vertex : SV_POSITION;
    };

    sampler2D _MainTex;
    sampler2D _ShadowTex;
    float4 _MainTex_ST;
    fixed _Scale;
    fixed _OffsetX;
    fixed _OffsetY;

    v2f vert(appdata_full v)
    {
        v2f o;

        o.vertex = UnityObjectToClipPos(v.vertex);
        o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
        fixed4 vertPose = mul(unity_ObjectToWorld, v.vertex);
        o.projectedUV = vertPose.xz + fixed2(_OffsetX, _OffsetY);
        o.projectedUV = o.uvs / _Scale;

        UNITY_TRANSFER_FOG(o,o.vertex);
        return o;
    }

    fixed4 frag(v2f i) : SV_Target
    {
        fixed4 col = tex2D(_MainTex, i.uv);
        fixed4 col2 = tex2D(_ShadowTex, i.projectedUV) * col;
        UNITY_APPLY_FOG(i.fogCoord, col2);
        return col2;
    }
        ENDCG
    }
    }
}

Шейдер, по сути, использует наш снимок теней, приводит координаты вертекса к реальным мировым координатам и затем затеняет объект. Отдельно для каждого объекта ничего не запекается, а карта теней — это одна текстура 1024 на 1024 пикселей для всего батлрояля.

В результате деревья у подножия горы действительно находятся в тени. Не супер реалистично, но этого достаточно, чтобы не нарушать игровой опыт.

Пример работы затенения: на кубическую сосну падает тень от кубического элеватора
Пример работы затенения: на кубическую сосну падает тень от кубического элеватора
И наглядная демонстрация 
И наглядная демонстрация 

Вторая карта

Даже оптимизированную карту 2 на 2 километра не все мобильные девайсы смогли потянуть. Чтобы не лишать игроков нового режима, мы сделали мини-версию карты размером 800 на 800 метров, применив те же самые инструменты.

Экран выбора карты
Экран выбора карты

Пользователи могут сами выбрать, на какой карте им играть. Но первой всегда предлагается та, которую точно потянет устройство (это автоматически проверяется). В какой-то момент мы даже добавили предупреждающий поп-ап, который появлялся на слабых девайсах и сообщал о возможных проблемах с производительностью. Его потом убрали — игрокам не нравится, когда им говорят, что у них на телефоне что-то не запустится.

А недавно на текущих технологиях мы выпустили уже третью карту 1,5×1,5 км — всего за месяц работы команды из восьми 3D-дизайнеров, левел-дизайнера и технического художника.

Третья карта для батлрояля
Третья карта для батлрояля

Постскриптум

Это только часть работы — здесь я затронул графику и все, что с ней связано. 

В следующем материале поговорим про сетевую часть:

  • Как оптимизировали трафик и код, что траты на сервер сократились на 20%.

  • Расскажу, как с помощью системы пулов срезали количество сетевых сообщений, чтобы не перегружать устройства игроков.

  • Почему выбрали сужение зоны по сценарию, а не рандомное (для этого написали отдельный редактор).

Tags:
Hubs:
Total votes 66: ↑66 and ↓0+66
Comments20

Articles

Information

Website
lightmap.com
Registered
Founded
Employees
101–200 employees
Location
Кипр