Хабрахабр

[Из песочницы] Скелетная анимация на стороне видеокарты

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

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

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

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

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

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

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

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

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

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

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

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

// проходим по всем кадрам в клипе
for (var frameIndex = 0; frameIndex < totalFramesInClip; ++frameIndex)

}

Следовательно с целью небольшой оптимизации его можно пропустить. Матрицы имеют размерность 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);

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

Ниже приведен код для записи этих данных в uv. Добавим дополнительный набор текстурных координат, в который сохраним для каждой вершины ассоциированные с ней индексы костей и веса влияния кости на эту вершину.
Unity предоставляет структуру данных, в которой возможны до 4 костей для одной вершины. Сохраняем индексы костей в 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;

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

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

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

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

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

var offset = clipStart + frameIndex * bonesCount * 3.0f

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

Выводы

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

image

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

image

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

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

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

Ссылки

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

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

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

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть