Не так давно 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(безбожно проигрывает), что неудивительно.
Анимация на видеокарте это еще один инструмент, который может быть использован в вашем приложении. Но как и все инструменты он должен быть использован с умом и к месту.
Ссылки
- Animation Instancing — Instancing for SkinnedMeshRenderer
- GPUSkinning — improve performance.
- UniteAustinTechnicalPresentation — ECS demo
[GitHub код проэкта]
Спасибо за внимание.
PS: Я новичок в Unity и не знаю всех тонкостей, статья может содержать неточности. Надеюсь исправить их с вашей помощью и разобраться в теме лучше.