Скелетная анимация на стороне видеокарты

в 9:34, , рубрики: gpu, programming, shader, unity, unity3d, Видеокарты, разработка игр

Не так давно Unity представила ECS. В процессе изучения мне стала интерестно, а каким образом можно подружить анимацию и ECS. И в процессе поиска я наткнулся на интересную технику, которую применяли ребята из NORDVEUS в своем демо для доклада Unite Austin 2017.
Unite Austin 2017 — Massive Battle in the Spellsouls Universe.

Доклад содержит много интересных решений но сегодня пойдет речь о сохранении скелетной анимации в текстуре с целью дальнейшего ее применения.

Зачем такие сложности, спросите вы?

Ребята из NORDVEUS одновременно отрисовывали на экране большое количество однотипных анимированных объектом: скелетов, мечников. В случае использования традиционного подхода: SkinnedMeshRenderers и AnimationAnimator, повлечет за собой увеличение вызовов отрисовки и дополнительную нагрузке на CPU по просчету анимации. И чтобы решить эти проблемы анимацию перенесли на сторону GPU, а точнее в вершинный шейдер.

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

Итак давайте нарежем слона на кусочки:

  • Получение ключей анимации из клипов
  • Сохранение данных в текстуру
  • Подготовка сетки (меша)
  • Шейдер
  • Собираем все вместе

Получение ключей анимации из клипов анимации

Из компонент SkinnedMeshRenderers достаем массив костей и меш. Компонент Animation предоставляет список доступных анимаций. Итак для каждого клипа мы должны покадрово сохранить матрицы трансформации для всех костей меша. Иными словами мы сохраняем позу персонажа в единицу времени.

Выделяем двумерный массив, в котором будут сохранены данные. Одно измерение которого имеет количество кадров умноженное на длину клипа в секундах. Другое — общее количество костей в меше:

var boneMatrices = new Matrix4x4[Mathf.CeilToInt(frameRate * clip.length), renderer.bones.Length];

В следующем примере поочередно меняем кадры для клипа и сохраняем матрицы:

// проходим по всем кадрам в клипе
for (var frameIndex = 0; frameIndex < totalFramesInClip; ++frameIndex)
{
  // нормализуем время: 0 - начало клипа, 1 - конец. 
  var normalizedTime = (float) frameIndex / totalFramesInClip;

  // выставляем время семплируемого кадра
  animationState.normalizedTime = normalizedTime;
  animation.Sample();

  // проходим  по всем костям
  for (var boneIndex = 0; j < renderer.bones.Length; boneIndex++)
  {
    // рассчитываем матрицу трансформации для кости в этот кадр
    var matrix = renderer.bones[boneIndex].localToWorldMatrix *
                       renderer.sharedMesh.bindposes[boneIndex];
      
    // сохраняем матрицу             
    boneMatrices[i, j] = matrix;                             
  }
}

Матрицы имеют размерность 4 на 4 но последний рядок всегда выглядит как (0, 0, 0, 1). Следовательно с целью небольшой оптимизации его можно пропустить. Что в свою очередь сократит расходы на передачу данных между процессором и видеокартой.

a00 a01 a02 a03
a10 a11 a12 a13
a20 a21 a22 a23
0   0   0   1

Сохранение данных в текстуру

Чтоб высчитать размер текстуры перемножим общее число кадров во всех клипах анимации на количество костей и на количество рядков в матрице(мы договорились что сохраняем первых 3 рядка).

var dataSize = numberOfBones * numberOfKeyFrames * MATRIX_ROWS_COUNT);	
// рассчитываем ширину и высоту текстуры 
var size = NextPowerOfTwo((int) Math.Sqrt(dataSize));
var texture = new Texture2D(size, size, TextureFormat.RGBAFloat, false)
{
  wrapMode = TextureWrapMode.Clamp,
  filterMode = FilterMode.Point,
  anisoLevel = 0
};

Записываем данные в текстуру. Для каждого клипа покадрово сохраняем матрицы трансформации. Формат данных следующий. Клипы записываются последовательно один за одним и состоят из набора кадров. Которые в свою очередь состоят из набора костей. Каждая кость содержит в себе 3 рядка матрицы.

Clip0[Frame0[Bone0[row0,row1,row2]...BoneN[row0,row1,row2].]...FramM[bone0[row0,row1,row2]...ClipK[...]

Ниже приведен код сохранения данных:

var textureColor = new Color[texture.width * texture.height];

var clipOffset = 0;
for (var clipIndex = 0; clipIndex < sampledBoneMatrices.Count; clipIndex++)
{
  var framesCount = sampledBoneMatrices[clipIndex].GetLength(0);
  for (var keyframeIndex = 0; keyframeIndex < framesCount; keyframeIndex++)
  {
    var frameOffset = keyframeIndex * numberOfBones * 3;
    for (var boneIndex = 0; boneIndex < numberOfBones; boneIndex++) 
    {
      var index = clipOffset + frameOffset + boneIndex * 3;
      var matrix = sampledBoneMatrices[clipIndex][keyframeIndex, boneIndex];
                        
      textureColor[index + 0] = matrix.GetRow(0);
      textureColor[index + 1] = matrix.GetRow(1);
      textureColor[index + 2] = matrix.GetRow(2);
    }
  }
}
texture.SetPixels(textureColor);
texture.Apply(false, false);

Подготовка сетки (меша)

Добавим дополнительный набор текстурных координат, в который сохраним для каждой вершины ассоциированные с ней индексы костей и веса влияния кости на эту вершину.
Unity предоставляет структуру данных, в которой возможны до 4 костей для одной вершины. Ниже приведен код для записи этих данных в uv. Сохраняем индексы костей в UV1, веса в UV2.

var boneWeights = mesh.boneWeights;

var boneIds = new List<Vector4>(mesh.vertexCount);
var boneInfluences = new List<Vector4>(mesh.vertexCount);
for (var i = 0; i < mesh.vertexCount; i++)
{
  boneIds.Add(new Vector4(bw.boneIndex0, bw.boneIndex1, bw.boneIndex2, bw.boneIndex3);
  boneInfluences.Add(new Vector4(bw.weight0, bw.weight1, bw.weight2, bw.weight3));
}

mesh.SetUVs(1, boneIds);
mesh.SetUVs(2, boneInfluences);

Шейдер

Основная задача шейдера найти матрицу трансформации для кости ассоциированной с вершиной и перемножить координаты вершины на эту матрицу. Для этого нам и понадобиться дополнительный набор координат с индексами и весами костей. Еще нам понадобиться индекс текущего кадра он будет меняться с течением времени и будет передаваться со стороны CPU.

// frameOffset = clipOffset + frameIndex * clipLength * 3 - рассчитываем это на стороне CPU
// boneIndex - индес костти к котрой привязана вершина, берем из UV1
int index = frameOffset + boneIndex * 3;

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

inline float4 IndexToUV(int index, float2 size) 
{
  return float4(((float)((int)(index % size.x)) + 0.5) / size.x, 
		 ((float)((int)(index / size.x)) + 0.5) / size.y, 
		 0, 
		 0);
}

Вычитав строки собираем матрицу не забыв про последний рядок, который всегда равен (0, 0, 0, 1).

float4 row0 = tex2Dlod(frameOffset, IndexToUV(index + 0, animationTextureSize));
float4 row1 = tex2Dlod(frameOffset, IndexToUV(index + 1, animationTextureSize));
float4 row2 = tex2Dlod(frameOffset, IndexToUV(index + 2, animationTextureSize));
float4 row3 = float4(0, 0, 0, 1);

return float4x4(row0, row1, row2, row3);

Одновременно на одну вершину могут влиять сразу несколько костей. Результирующая матрица будет суммой всех матриц влияющих на вершину умноженных на вес их влияния.

float4x4 m0 = CreateMatrix(frameOffset, bones.x) * boneInfluences.x;
float4x4 m1 = CreateMatrix(frameOffset, bones.y) * boneInfluences.y;
float4x4 m2 = CreateMatrix(frameOffset, bones.z) * boneInfluences.z;
float4x4 m3 = CreateMatrix(frameOffset, bones.w) * boneInfluences.w;

return m0 + m1 + m2 + m3;

Получив матрицу перемножаем её на координаты вершины. Следовательно все вершины будут перемещены в позу персонажа, которая соответствует текущему кадру. Меняя кадр, мы будем анимировать персонажа.

Собираем все вместе

Для отображения объектов будем использовать Graphics.DrawMeshInstancedIndirect, в который передадим подготовленный меш и материал. Также в материал мы должны передать текстуру с анимациями размер текстуры и массив с указателями на кадр для каждого объекта в текущий момент времени. В качестве дополнительной информации мы передаем позицию для каждого объекта и поворот. Как изменить позицию и поворот на стороне шейдера можно посмотреть в [статье].

В методе Update увеличиваем время пройденное с начала анимации на Time.deltaTime.

Для того чтобы высчитать индекс кадра мы должны нормализовать время поделив его на длину клипа. Следовательно индекс кадра в клипе будет произведением нормализованного времени на количество кадров. А индекс кадра в текстуре будет суммой сдвига начала текущего клипа и произведением текущего кадра на объем данных хранящийся в этом кадре.

var offset = clipStart + frameIndex * bonesCount * 3.0f

Вот наверное и все передав все данные в шейдер вызываем Graphics.DrawMeshInstancedIndirect с подготовленным мешем и материалом.

Выводы

Тестировании этой техники на машине с видеокартой 1050 показало прирост производительности приблизительно в 2 раза.

image

Анимирование 4000 однотипных объектов на CPU

image

Анимирование 8000 однотипных объектов на GPU

В тоже время тестирование этой же сцены на macbook pro 15 с интегрированной видеокартой показывает не завидный результат в пользу GPU(безбожно проигрывает), что неудивительно.

Анимация на видеокарте это еще один инструмент, который может быть использован в вашем приложении. Но как и все инструменты он должен быть использован с умом и к месту.

Ссылки

[GitHub код проэкта]

Спасибо за внимание.

PS: Я новичок в Unity и не знаю всех тонкостей, статья может содержать неточности. Надеюсь исправить их с вашей помощью и разобраться в теме лучше.

Автор: AndroFARsh

Источник

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


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