Animação esquelética na lateral da placa de vídeo

A Unity introduziu recentemente o ECS. No processo de estudo, fiquei interessado em como a animação e o ECS podem ser feitos amigos. E no processo de busca, deparei com uma técnica interessante que os caras da NORDVEUS usaram em sua demo para o relatório Unite Austin 2017.
Unite Austin 2017 - Batalha maciça no universo de Spellsouls.


O relatório contém muitas soluções interessantes, mas hoje discutiremos a preservação da animação esquelética na textura, com vistas à sua posterior aplicação.


Por que essas dificuldades, você pergunta?


Os caras da NORDVEUS pintaram simultaneamente na tela um grande número do mesmo tipo de objeto animado: esqueletos, espadachins. No caso de usar a abordagem tradicional: SkinnedMeshRenderers e Animation \ Animator , implicará um aumento nas chamadas de empate e uma carga adicional na CPU para renderizar a animação. E para resolver esses problemas, a animação foi movida para o lado da GPU, ou melhor, para o shader de vértice.



Eu estava muito interessado na abordagem e decidi descobri-la com mais detalhes. Como não encontrei artigos sobre esse tópico, entrei no código. No processo de estudo da questão, nasceu este artigo e minha visão de resolver esse problema.


Então, vamos cortar o elefante em pedaços:



  • Obtendo chaves de animação de clipes
  • Salvando dados na textura
  • Preparação de malha (malha)
  • Shader
  • Juntando tudo


Obtendo chaves de animação de clipes de animação


Dos componentes do SkinnedMeshRenderers, obtemos uma matriz de ossos e uma malha. O componente Animação fornece uma lista de animações disponíveis. Portanto, para cada clipe, devemos salvar a matriz de transformação quadro a quadro para todos os ossos da malha. Em outras palavras, mantemos a pose do personagem por unidade de tempo.


Selecionamos uma matriz bidimensional na qual os dados serão armazenados. Uma dimensão com o número de quadros vezes a duração do clipe em segundos. Outro é o número total de ossos na malha:


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

No exemplo a seguir, alteramos os quadros do clipe um por um e salvamos as matrizes:


 //       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; } } 

As matrizes são 4 por 4, mas a última linha sempre se parece com (0, 0, 0, 1). Portanto, para fins de otimização leve, ele pode ser ignorado. O que, por sua vez, reduzirá o custo da transferência de dados entre o processador e a placa de vídeo.


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

Salvando dados na textura


Para calcular o tamanho da textura, multiplicamos o número total de quadros em todos os clipes de animação pelo número de ossos e pelo número de linhas na matriz (concordamos em salvar as 3 primeiras linhas).


 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 }; 

Nós escrevemos os dados na textura. Para cada clipe, salvamos a matriz de transformação quadro a quadro. O formato dos dados é o seguinte. Os clipes são gravados seqüencialmente um por um e consistem em um conjunto de quadros. Que por sua vez consistem em um conjunto de ossos. Cada osso contém 3 linhas de matriz.


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

A seguir está o código para salvar:


 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); 

Preparação de malha (malha)


Adicione um conjunto adicional de coordenadas de textura nas quais, para cada vértice, salvamos os índices ósseos associados e o peso da influência do osso nesse vértice.
O Unity fornece uma estrutura de dados na qual são possíveis até 4 ossos para um vértice. Abaixo está o código para gravar esses dados em UV. Economizamos índices ósseos no UV1, pesos no 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); 

Shader


A principal tarefa do shader é encontrar a matriz de transformação para o osso associado ao vértice e multiplicar as coordenadas do vértice por essa matriz. Para fazer isso, precisamos de um conjunto adicional de coordenadas com índices e pesos ósseos. Também precisamos do índice do quadro atual, ele mudará com o tempo e será transmitido a partir da CPU.


 // frameOffset = clipOffset + frameIndex * clipLength * 3 -     CPU // boneIndex -      ,   UV1 int index = frameOffset + boneIndex * 3; 

Portanto, obtivemos o índice da primeira linha da matriz, o índice da segunda e da terceira será +1, +2, respectivamente. Resta traduzir o índice unidimensional em coordenadas normalizadas da textura e, para isso, precisamos do tamanho da textura.


 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); } 

Após subtrair as linhas, coletamos a matriz sem esquecer a última linha, que é sempre igual a (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); 

Ao mesmo tempo, vários ossos podem afetar um vértice de uma só vez. A matriz resultante será a soma de todas as matrizes que afetam o vértice multiplicado pelo peso de sua influência.


 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; 

Depois de receber a matriz, nós a multiplicamos pelas coordenadas do vértice. Portanto, todos os vértices serão movidos para a pose do personagem, que corresponde ao quadro atual. Mudando o quadro, vamos animar o personagem.


Juntando tudo


Para exibir objetos, usaremos Graphics.DrawMeshInstancedIndirect, para o qual transferiremos a malha e o material preparados. Além disso, no material, devemos passar a textura com animações para o tamanho da textura e uma matriz com ponteiros para o quadro de cada objeto no momento atual. Como informação adicional, passamos a posição para cada objeto e a rotação. Como alterar a posição e a rotação no lado do sombreador pode ser encontrado em [artigo] .


No método Update, aumente o tempo decorrido desde o início da animação em Time.deltaTime.


Para calcular o índice de quadros, precisamos normalizar o tempo dividindo-o pela duração do clipe. Portanto, o índice de quadros no clipe será o produto do tempo normalizado pelo número de quadros. E o índice de quadros na textura será a soma da mudança do início do clipe atual e o produto do quadro atual pela quantidade de dados armazenados nesse quadro.


 var offset = clipStart + frameIndex * bonesCount * 3.0f 

Provavelmente, todos esses dados foram passados ​​para o shader, denominamos Graphics.DrawMeshInstancedIndirect com a malha e o material preparados.


Conclusões


Testar essa técnica em uma máquina com uma placa gráfica 1050 mostrou um aumento de desempenho de cerca de 2 vezes.


imagem

Animação de 4000 objetos do mesmo tipo na CPU


imagem

Animação de 8000 objetos do mesmo tipo na GPU


Ao mesmo tempo, testar esta cena em um macbook pro 15 com uma placa gráfica integrada mostra o resultado oposto. A GPU descaradamente perde (cerca de 2-3 vezes), o que não é surpreendente.


A animação na placa de vídeo é outra ferramenta que pode ser usada no seu aplicativo. Mas, como todas as ferramentas, deve ser usada com sabedoria e fora de lugar.


Referências




[Código do projeto GitHub]

Obrigado pela atenção.


PS: Sou novo no Unity e não conheço todas as sutilezas, o artigo pode conter imprecisões. Espero corrigi-los com sua ajuda e entender melhor o tópico.


Source: https://habr.com/ru/post/pt452416/


All Articles