Делаем траву в Unity при помощи GPU Instancing

в 5:47, , рубрики: shadergraph, unity, unity3d

Добрый день! Хочу поделиться с вами некоторым опытом по оптимизации с использованием GPU Instancing.

Постановка задачи примерно такая: игра под мобильные платформы, одним из элементов которой является поле с травой. Фотореалистичность не требуется, low poly стиль. Но при этом игрок должен иметь возможность взаимодействовать с травой. В нашем случае выкашивать. Делать будем на Unity 2021.2.7 (но жесткой привязки к версии нет) с URP.

Конечный результат можно увидеть на гифке.

Делаем траву в Unity при помощи GPU Instancing - 1

Приготовления

Для начала создадим нашу low poly травинку. Так как камера в игре никогда не вращается, то можно немного поэкономить и использовать только два треугольника:

Делаем траву в Unity при помощи GPU Instancing - 2

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

Делаем траву в Unity при помощи GPU Instancing - 3

Теперь сделаем Shader Subgraph, который будем использовать в наших дальнейших экспериментах:

Делаем траву в Unity при помощи GPU Instancing - 4

Немного пояснений. На вход подается несколько параметров, ответственных за ветер и мировая позиция вершины. Для вычисления ее сдвига ветром мы прогоняем позицию через Simple Noise чтобы все травинки на сцене колыхались не синхронно. И далее через UV и Lerp мы определяем насколько ветровой сдвиг должен быть применен к данной вершине.

Теперь можно проводить наши эксперименты.

Так же хочу сразу отметить, что что все замеры производительности будут производиться на не самом быстром Xiaomi Redmi 8A

Вариант "в лоб"

Прежде чем что-то оптимизировать мы должны убедиться, что в этом есть необходимость. Посмотрим как покажет себя SRP Batcher на том количестве травинок, которое будет устраивать нас визуально.

Сперва сделаем полноценный шейдер:

Делаем траву в Unity при помощи GPU Instancing - 5

Теперь приступаем к засеву поляны:

_startPosition = -_fieldSize / 2.0f;
_cellSize = new Vector2(_fieldSize.x / GrassDensity, _fieldSize.y / GrassDensity);

var grassEntities = new Vector2[GrassDensity, GrassDensity];
var halfCellSize = _cellSize / 2.0f;

for (var i = 0; i < grassEntities.GetLength(0); i++) {
  for (var j = 0; j < grassEntities.GetLength(1); j++) {
    grassEntities[i, j] =
    new Vector2(_cellSize.x * i + _startPosition.x, _cellSize.y * j + _startPosition.y) 
      + new Vector2(Random.Range(-halfCellSize.x, halfCellSize.x),
		    Random.Range(-halfCellSize.y, halfCellSize.y));
  }
}
_abstractGrassDrawer.Init(grassEntities, _fieldSize);

Генерируем равномерное поле с небольшой долей случайности. Оно у нас плоское, поэтому Vector2 вполне достаточно для координат. Далее передаем этот массив координат на "отрисовку". Сейчас это просто расстановка GameObject в нужном количестве.

public override void Init(Vector2[,] grassEntities, Vector2 fieldSize) {
  _grassEntities = new GameObject[grassEntities.GetLength(0), grassEntities.GetLength(1)];
  for (var i = 0; i < grassEntities.GetLength(0); i++) {
    for (var j = 0; j < grassEntities.GetLength(1); j++) {
      _grassEntities[i, j] = Instantiate(_grassPrefab,
      new Vector3(grassEntities[i, j].x, 0.0f, grassEntities[i, j].y), Quaternion.identity);
    }
  }
}

Получаем такую картину:

Делаем траву в Unity при помощи GPU Instancing - 6

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

GPU Instancing

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

В инспекторе материала нашей травы можно найти галочку "Enable GPU Instancing" и включить ее. Но, к сожалению, все не так просто. Если мы запустим сцену и посмотрим на Frame Debugger, то увидим, что по-прежнему работает SRP Batcher, но не GPU Instancing. Все дело в том, что эти две технологии несовместимы, поэтому нам надо как-то отключить SRP Batcher. Есть несколько способов сделать это.

1. Выключить в настройках URP

Этот способ вряд ли можно назвать рекомендованным. Не зря в свежих версиях Unity эту галочку убрали из инспектора. Но найти ее по-прежнему можно через Debug режим инспектора вашего URP ассета. Нам этот способ не подходит, потому что кроме травы в игре есть много других вещей, для которых SRP Batcher нас устраивает.

2. Сделать несовместимым шейдер

Способ не самый удобный, потому что необходимо отредактировать сам шейдер. А в случае Shader Graph необходимо сначала еще сгенерировать код шейдера, который потом отредактировать. И при каждом изменении графа эту операцию нужно повторять. Далее согласно документации: "Add a new material property declaration into the shader’s Properties block. Don’t declare the new material property in the UnityPerMaterial constant buffer". Почему-то в моем случае это не сработало и шейдер оставался SRP compatible, поэтому я просто закомментировал объявление буфера:

//CBUFFER_START(UnityPerMaterial)
float4 _MainTexture_TexelSize;
half _WindShiftStrength;
half _WindSpeed;
half _WindStrength;
//CBUFFER_END

Несмотря на неудобство этого способа, я выбрал именно его. Просто чтобы механизм инстанцирования и сами префабы оставались теми же, что и в первом эксперименте. 

3. Сделать несовместимым рендерер

Пожалуй, самый простой способ. К тому же наверняка может потребоваться использовать один шейдер с SRP Batcher и с GPU Instancing. Для этого достаточно назначить рендереру объекта MaterialPropertyBlock, потому что он не совместим с SRP Batcher. К тому же MaterialPropertyBlock может иметь практическую пользу. Допустим если бы мы захотели реализовать асинхронное колыхание травы не через случайный шум, а каким-то своим параметром. Либо раскрашивать траву при нажатии на нее.

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

Делаем траву в Unity при помощи GPU Instancing - 7

Можно сделать замеры:

Делаем траву в Unity при помощи GPU Instancing - 8

Graphics API

Отрисовать меш с использованием GPU Instancing можно так же напрямую через Graphics API, без необходимости использования GameObject. Для этого существуют 3 метода.

1. Graphics.DrawMeshInstanced

Самый простой способ. Передаем меш, материал и массив матриц. Не требует создания специального шейдера, но имеет ограничение в 1023 инстанса за один вызов. Маловато будет.

2. Graphics.DrawMeshInstancedIndirect

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

3. Graphics.DrawMeshInstancedProcedural

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

Для начала передадим позиции наших травинок в материал:

_positionsCount = _positions.Count;
_positionBuffer?.Release();
if (_positionsCount == 0) return;
_positionBuffer = new ComputeBuffer(_positionsCount, 8);
_positionBuffer.SetData(_positions);
_instanceMaterial.SetBuffer(PositionsShaderProperty, _positionBuffer);

Далее сама отрисовка, которая должна вызываться каждый кадр:

private void Update() {
  if (_positionsCount == 0) return;
  Graphics.DrawMeshInstancedProcedural(_instanceMesh, 0, _instanceMaterial,
	  _grassBounds, _positionsCount,
	  null, ShadowCastingMode.Off, false);
}

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

Делаем траву в Unity при помощи GPU Instancing - 9

Нам потребовались две ноды с кастомными функциями.

 Первая нужна нам для того чтобы мы могли воспользоваться unity_InstanceID, с помощью которого мы будем вытаскивать позицию инстанса из буфера. Само решение было найдено где-то в интернетах и полное теоретическое обоснование на данный момент дать не могу. Буду признателен за ссылки с пояснениями.

#pragma instancing_options procedural:ConfigureProcedural
Out = In;

Вторая функция - непосредственно извлечение позиции из буфера.

#if defined(UNITY_PROCEDURAL_INSTANCING_ENABLED)
StructuredBuffer<float2> PositionsBuffer;
#endif

float2 position;

void ConfigureProcedural () {
	#if defined(UNITY_PROCEDURAL_INSTANCING_ENABLED)
	position = PositionsBuffer[unity_InstanceID];
	#endif
}

void ShaderGraphFunction_float (out float2 PositionOut) {
	PositionOut = position;
}

void ShaderGraphFunction_half (out half2 PositionOut) {
	PositionOut = position;
}

Теперь сделаем замеры:

Делаем траву в Unity при помощи GPU Instancing - 10

Кроме неплохой производительности данный способ позволяет использовать Compute Shader для вычисления значений в буфере. Например вынести вычисления для летающей по сцене травы из C# в шейдер. Например так.

Еще немного оптимизации

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

В классе нашей поляны будем следить за камерой и вычислять границы.

private void Update() {
	if (_camera.transform.hasChanged) {
		UpdateCameraCells();
	}
}

private Vector3 Raycast(Vector3 position) {
	var ray = _camera.ScreenPointToRay(position);
	_plane.Raycast(ray, out var enter);
	return ray.GetPoint(enter);
}

private void UpdateCameraCells() {
	if (!PerformCulling) {
		_abstractGrassDrawer.UpdatePositions(Vector2Int.zero, new Vector2Int(GrassDensity, GrassDensity));
		return;
	}
	var bottomLeftCameraCorner = Raycast(Vector3.zero);
	var topLeftCameraCorner = Raycast(new Vector3(0.0f, Screen.height));
	var topRightCameraCorner = Raycast(new Vector3(Screen.width, Screen.height));
	var bottomLeftCameraCell = new Vector2Int(
		Mathf.Clamp(Mathf.FloorToInt((topLeftCameraCorner.x - _startPosition.x) / _cellSize.x), 0,
			GrassDensity - 1),
		Mathf.Clamp(Mathf.FloorToInt((bottomLeftCameraCorner.z - _startPosition.y) / _cellSize.y), 0,
			GrassDensity - 1));

	var topRightCameraCell = new Vector2Int(
		Mathf.Clamp(Mathf.FloorToInt((topRightCameraCorner.x - _startPosition.x) / _cellSize.x) + 1, 0,
			GrassDensity - 1),
		Mathf.Clamp(Mathf.FloorToInt((topRightCameraCorner.z - _startPosition.y) / _cellSize.y) + 1, 0,
			GrassDensity - 1));
	_abstractGrassDrawer.UpdatePositions(bottomLeftCameraCell, topRightCameraCell);
}

В instanced отрисовке добавим обновление буфера.

public override void UpdatePositions(Vector2Int bottomLeftCameraCell, Vector2Int topRightCameraCell) {
	_positions.Clear();
	for (var i = bottomLeftCameraCell.x; i < topRightCameraCell.x; i++) {
		for (var j = bottomLeftCameraCell.y; j < topRightCameraCell.y; j++) {
			_positions.Add(_grassEntities[i, j]);
		}
	}

	_positionsCount = _positions.Count;
	_positionBuffer?.Release();
	if (_positionsCount == 0) return;
	_positionBuffer = new ComputeBuffer(_positionsCount, 8);
	_positionBuffer.SetData(_positions);
	_instanceMaterial.SetBuffer(PositionsShaderProperty, _positionBuffer);
}

В GameObject отрисовке будем включать/отключать объекты травы.

public override void UpdatePositions(Vector2Int bottomLeftCameraCell, Vector2Int topRightCameraCell) {
  for (var i = 0; i < _grassEntities.GetLength(0); i++) {
    for (var j = 0; j < _grassEntities.GetLength(1); j++) {
      _grassEntities[i, j].SetActive(i >= bottomLeftCameraCell.x && i < topRightCameraCell.x && j >= bottomLeftCameraCell.y &&
      	j < topRightCameraCell.y);
    }
  }
}

Ну и сделаем замеры нашего самого производительного варианта.

Делаем траву в Unity при помощи GPU Instancing - 11

А вот так это будет выглядеть на приемлемых для многих игр ~30 fps.

Делаем траву в Unity при помощи GPU Instancing - 12

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

Подводные камни

К сожалению, инстансинг поддерживается не на всех девайсах. Проверить это можно через SystemInfo.supportsInstancing. Для этих девайсов можно включать fallback вариант со старыми добрыми GameObject, просто уменьшив их количество.

И конечно же отличились Samsung с как минимум Galaxy A50. Несмотря на то, что SystemInfo.supportsInstancing возвращает true (что по всей видимости правда, потому что эксепшенов нет), но что-то у них не так с буфером. Вся трава на данном девайсе упорно рисуется в одной точке. Поэтому пришлось делать проверку костылями. Но об это можно поговорить в другой раз, если кому-то будет интересно. 

Заключение

В данной статье я не преследовал цели глубокого сравнения и анализа отличий SRP Batcher и GPU Instancing. Да и сам инстансинг описан мной довольно поверхностно. Мне скорее хотелось рассказать про то где он может быть полезен. И как он помог решить одну конкретную задачу.

Проект из статьи можно найти вот здесь.

Справедливая критика приветствуется. Спасибо за внимание!

Автор: Антон Алексеев

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js