Не так давно Unity представила ECS. В процессе изучения мне стала интерестно, а каким образом можно подружить анимацию и ECS. И в процессе поиска я наткнулся на интересную технику, которую применяли ребята из NORDVEUS в своем демо для доклада Unite Austin 2017.
Unite Austin 2017 — Massive Battle in the Spellsouls Universe.
Доклад содержит много интересных решений но сегодня пойдет речь о сохранении скелетной анимации в текстуре с целью дальнейшего ее применения.
Зачем такие сложности, спросите вы?
Ребята из NORDVEUS одновременно отрисовывали на экране большое количество однотипных анимированных объектом: скелетов, мечников. В случае использования традиционного подхода: SkinnedMeshRenderers и Animation\Animator, повлечет за собой увеличение вызовов отрисовки и дополнительную нагрузке на 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 раза.

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

Анимирование 8000 однотипных объектов на GPU
В тоже время тестирование этой сцены на macbook pro 15 с интегрированной видеокартой показывает обратный результат. GPU безбожно проигрывает (приблизительно в 2-3 раза), что неудивительно.
Анимация на видеокарте это еще один инструмент, который может быть использован в вашем приложении. Но как и все инструменты он должен быть использован с умом и к месту.
Ссылки
- Animation Instancing — Instancing for SkinnedMeshRenderer
- GPUSkinning — improve performance.
- UniteAustinTechnicalPresentation — ECS demo
[GitHub код проэкта]
Спасибо за внимание.
PS: Я новичок в Unity и не знаю всех тонкостей, статья может содержать неточности. Надеюсь исправить их с вашей помощью и разобраться в теме лучше.
