Limite de GPU. Como transferir tudo para a placa de vídeo e um pouco mais. Animações

Era uma vez um grande evento quando uma unidade de múltiplas estruturas ou transformação e iluminação de hardware (T&L) apareceu na GPU. Definir o pipeline de função fixa foi um xamanismo mágico. E aqueles que sabiam habilitar e usar os recursos avançados de chips específicos através dos hacks da API D3D9, consideraram ter aprendido o Zen. Mas o tempo passou, apareceram shaders. No início, fortemente limitado tanto na funcionalidade quanto no comprimento. Além disso, mais e mais recursos, mais instruções, mais velocidade. A computação (CUDA, OpenCL, DirectCompute) apareceu e o escopo das capacidades da placa de vídeo começou a se expandir rapidamente.

Nesta série de artigos (espero), tentarei explicar e mostrar como "de maneira incomum" você pode aplicar os recursos da GPU moderna ao desenvolver jogos, além de efeitos gráficos. A primeira parte será dedicada ao sistema de animação. Tudo o que é descrito é baseado em experiência prática, implementado e funciona em projetos de jogos reais.

Oooo, novamente a animação. Sobre isso, uma centena de vezes já foi escrita e descrita. O que é tão complicado? Empacotamos a matriz de ossos no buffer / textura e a usamos para esfolar no shader de vértice. Isso foi descrito na GPU Gems 3 (capítulo 2. Animação em multidão) . E implementado na recente apresentação técnica da Unite . É possível de outra maneira?

Technodemka de Unity


Muita publicidade, mas é realmente legal? Há um artigo no hub que descreve em detalhes como a animação esquelética é feita e funciona nesta demo tecno. Trabalho paralelo é tudo de bom, não os consideramos. Mas precisamos descobrir o que e como, em termos de renderização.

Em uma batalha em larga escala, dois exércitos lutam, cada um consistindo em um tipo de unidade. Esqueletos à esquerda, cavaleiros à direita. Variedade é tão-lo. Cada unidade consiste em 3 LODs (~ 300, ~ 1000, ~ 4000 vértices cada), e apenas 2 ossos afetam o vértice. O sistema de animação consiste em apenas 7 animações para cada tipo de unidade (lembro que já existem 2). As animações não se misturam, mas alternam discretamente do código simples que é executado no job'ax, enfatizado na apresentação. Não há máquina de estado. Quando temos dois tipos de malha, você pode atrair toda a multidão em duas instâncias. A animação esquelética, como já escrevi, é baseada na tecnologia descrita em 2009.
Inovador? Hmm ... um avanço? Um ... Adequado para jogos modernos? Bem, talvez, a proporção de FPS para o número de unidades ostente.

As principais desvantagens dessa abordagem (pré-matriz em texturas):

  1. Depende da taxa de quadros. Queria o dobro de quadros de animação - dê o dobro de memória.
  2. Falta de animações combinadas. Você pode fazê-las, é claro, mas no shader da pele uma confusão complexa se formará a partir da lógica de mistura.
  3. Falta de ligação à máquina de estado do Unity Animator. Uma ferramenta conveniente para personalizar o comportamento do personagem, que pode ser conectado a qualquer sistema de aparência, mas no nosso caso, devido ao ponto 2, tudo se torna muito difícil (imagine como misturar o BlendTree aninhado).

GPAS


Sistema de Animação com GPU. O nome acabou de aparecer.
O novo sistema de animação tinha vários requisitos:

  1. Trabalhe rápido (bem, compreensível). Você precisa animar dezenas de milhares de unidades diferentes.
  2. Seja um análogo completo (ou quase) do sistema de animação Unity. Se a animação estiver assim, no novo sistema ela deve ser exatamente a mesma. Capacidade de alternar entre sistemas internos de CPU e GPU. Isso geralmente é necessário para depuração. Quando as animações são "buggy", alternando para o animador clássico, você pode entender: estas são as falhas do novo sistema ou a própria máquina / animação de estado.
  3. Todas as animações são personalizáveis ​​no Unity Animator. Uma ferramenta conveniente, testada e, o mais importante, pronta para uso. Vamos construir bicicletas em outro lugar.

Vamos repensar a preparação e o cozimento de animações. Nós não vamos usar matrizes. Placas de vídeo modernas funcionam bem com loops, suportam nativamente int além de float, portanto, trabalharemos com quadros-chave como em uma CPU.

Vejamos um exemplo de animação no Visualizador de animação:



Pode-se observar que os quadros-chave são definidos separadamente para posição, escala e rotação. Para alguns ossos, você precisa de muitos deles, para alguns apenas alguns e para aqueles ossos que não são animados separadamente, apenas os quadros-chave inicial e final são definidos.

Posição - Vector3, quaternion - Vector4, escala - Vector3. A estrutura do quadro-chave terá algo em comum (para simplificação), portanto, precisamos de 4 float para ajustar-se a qualquer um dos tipos acima. Também precisamos de InTangent e OutTangent para a interpolação correta entre os quadros-chave de acordo com a curvatura. Ah, sim, e o tempo normalizado não esquece:

struct KeyFrame { float4 v; float4 inTan, outTan; float time; }; 

Para obter todos os quadros-chave, use AnimationUtility.GetEditorCurve ().
Além disso, devemos lembrar os nomes dos ossos, pois será necessário remapear os ossos da animação nos ossos do esqueleto (e eles podem não coincidir) na fase de preparação dos dados da GPU.

Preenchendo buffers lineares com matrizes de quadros-chave, lembraremos dos deslocamentos neles para encontrar aqueles que se relacionam com a animação de que precisamos.

Agora interessante. Animação de esqueleto de GPU.

Preparamos um buffer grande ("número de esqueletos animados" X "número de ossos no esqueleto" X "coeficiente empírico do número máximo de misturas de animação"). Nele, armazenaremos a posição, rotação e escala do osso no momento da animação. E para todos os ossos animados planejados nesse quadro, execute shader de computação. Cada segmento anima seu osso.

Cada quadro-chave, independentemente do tamanho ao qual pertence (Traduzir, Rotação, Escala), é interpolado exatamente da mesma forma (pesquisa por pesquisa linear, perdoe-me Knuth):

 void InterpolateKeyFrame(inout float4 rv, int startIdx, int endIdx, float t) { for (int i = startIdx; i < endIdx; ++i) { KeyFrame k0 = keyFrames[i + 0]; KeyFrame k1 = keyFrames[i + 1]; float lerpFactor = (t - k0.time) / (k1.time - k0.time); if (lerpFactor < 0 || lerpFactor > 1) continue; rv = CurveInterpoate(k0, k1, lerpFactor); break; } } 

A curva é uma curva cúbica de Bezier, portanto, a função de interpolação será a seguinte:

 float4 CurveInterpoate(KeyFrame v0, KeyFrame v1, float t) { float dt = v1.time - v0.time; float4 m0 = v0.outTan * dt; float4 m1 = v1.inTan * dt; float t2 = t * t; float t3 = t2 * t; float a = 2 * t3 - 3 * t2 + 1; float b = t3 - 2 * t2 + t; float c = t3 - t2; float d = -2 * t3 + 3 * t2; float4 rv = a * v0.v + b * m0 + c * m1 + d * v1.v; return rv; } 

A postura local (TRS) do osso foi calculada. Em seguida, com um shader de computação separado, combinamos todas as animações necessárias para esse osso. Para fazer isso, temos um buffer com índices e pesos de cada animação na mistura final. Obtemos essas informações da máquina de estado. A situação do BlendTree dentro do BlendTree é resolvida da seguinte maneira. Por exemplo, há uma árvore:



O BlendTree Walk terá um peso de 0,35, Run - 0,65. Assim, a posição final dos ossos deve ser determinada por 4 animações: Walk1, Walk2, Run1 e Run2. Seus pesos terão valores (0,35 * 0,92, 0,35 * 0,08, 0,65 * 0,92, 0,65 * 0,08) = (0,332, 0,028, 0,598, 0,052), respectivamente. Note-se que a soma dos pesos deve sempre ser igual a um, ou erros mágicos são fornecidos.

O "coração" da função de mesclagem:

 float bw = animDef.blendWeight; BoneXForm boneToBlend = animatedBones[srcBoneIndex]; float4 q = boneToBlend.quat; float3 t = boneToBlend.translate; float3 s = boneToBlend.scale; if (dot(resultBone.quat, q) < 0) q = -q; resultBone.translate += t * bw; resultBone.quat += q * bw; resultBone.scale += s * bw; 

Agora você pode traduzir em uma matriz de transformação. Parar Sobre a hierarquia dos ossos completamente esquecida.
Com base nos dados do esqueleto, construímos uma matriz de índices, onde a célula com o índice ósseo contém o índice de seu pai. Na raiz, escreva -1.

Um exemplo:



 float4x4 animMat = IdentityMatrix(); float4x4 mat = initialPoses[boneId]; while (boneId >= 0) { BoneXForm b = blendedBones[boneId]; float4x4 xform = MakeTransformMatrix(b.translate, b.quat, b.scale); animMat = mul(animMat, xform); boneId = bonesHierarchyIndices[boneId]; } mat = mul(mat, animMat); resultSkeletons[id] = mat; 

Aqui, em princípio, todos os principais pontos de renderização e mistura de animações.

GPSM


GPU Powered State Machine (você adivinhou certo). O sistema de animação descrito acima funcionaria perfeitamente com a Unity Animation State Machine, mas todos os esforços seriam inúteis. Com a possibilidade de calcular dezenas (senão centenas) de milhares de animações por quadro, o UnityAnimator não extrairá milhares de máquinas de estado que funcionam simultaneamente. Hmm ...
O que é uma máquina de estado no Unity? Este é um sistema fechado de estados e transições, controlado por propriedades numéricas simples. Cada máquina de estado opera independentemente uma da outra e para o mesmo conjunto de dados de entrada. Espere um pouco. Essa é uma tarefa ideal para a GPU e os shaders de computação!

Fase de cozimento

Primeiro, precisamos coletar e colocar todos os dados da máquina de estado em uma estrutura amigável à GPU. E isso: estados (estados), transições (transições) e parâmetros (parâmetros).
Todos esses dados são colocados em buffers lineares e tratados por índices.
Cada thread de computação considera sua máquina de estado. O AnimatorController fornece uma interface para todas as estruturas internas necessárias da máquina de estado.

As principais estruturas da máquina de estado:

 struct State { float speed; int firstTransition; int numTransitions; int animDefId; }; struct Transition { float exitTime; float duration; int sourceStateId; int targetStateId; int firstCondition; int endCondition; uint properties; }; struct StateData { int id; float timeInState; float animationLoop; }; struct TransitionData { int id; float timeInTransition; }; struct CurrentState { StateData srcState, dstState; TransitionData transition; }; struct AnimationDef { uint animId; int nextAnimInTree; int parameterIdx; float lengthInSec; uint numBones; uint loop; }; struct ParameterDef { float2 line0ab, line1ab; int runtimeParamId; int nextParameterId; }; struct Condition { int checkMode; int runtimeParamIndex; float referenceValue; }; 

  • Estado contém a velocidade com que o estado é jogado e os índices das condições para a transição para outros de acordo com a máquina de estado.
  • A transição contém índices de estado "de" e "para". Hora de transição, hora de saída e um link para uma variedade de condições para entrar nesse estado.
  • CurrentState é um bloco de dados de tempo de execução com dados no estado atual da máquina de estados.
  • AnimationDef contém uma descrição da animação com links para outras pessoas relacionadas a ela pelo BlendTree.
  • ParameterDef é uma descrição do parâmetro que controla o comportamento das máquinas de estado. Line0ab e Line1ab são os coeficientes da equação de linhas para determinar o peso da animação pelo valor do parâmetro. A partir daqui:


  • Condição - uma especificação da condição para comparar o valor de tempo de execução do parâmetro e o valor de referência.

Fase de tempo de execução

O ciclo principal de cada máquina de estado pode ser exibido usando o seguinte algoritmo:



Existem 4 tipos de parâmetros no animador do Unity: float, int, bool e trigger (que é bool). Vamos apresentar todos eles como float. Ao configurar as condições, é possível escolher um dos seis tipos de comparação. Se == Igual a. IfNot == NotEqual. Portanto, usaremos apenas 4. O índice do operador é passado para o campo checkMode da estrutura Condition.

 for (int i = t.firstCondition; i < t.endCondition; ++i) { Condition c = allConditions[i]; float paramValue = runtimeParameters[c.runtimeParamIndex]; switch (c.checkMode) { case 3: if (paramValue < c.referenceValue) return false; case 4: if (paramValue > c.referenceValue) return false; case 6: if (abs(paramValue - c.referenceValue) > 0.001f) return false; case 7: if (abs(paramValue - c.referenceValue) < 0.001f) return false; } } return true; 

Para iniciar a transição, todas as condições devem ser verdadeiras. Os rótulos de maiúsculas e minúsculas são apenas (int) AnimatorConditionMode. A lógica de interrupção é uma lógica complicada para interromper e reverter transições.

Depois de atualizar o estado da máquina de estado e rolar os carimbos de data e hora no quadro delta t, é hora de preparar dados sobre quais animações devem ser lidas nesse quadro e os pesos correspondentes. Esta etapa é pulada se o modelo da unidade não estiver no quadro (Frustum selecionado). Por que devemos considerar animações do que não é visível? Examinamos o estado de origem da árvore de mesclagem, de acordo com o estado de destino da árvore de mesclagem, adicionamos todas as animações e calculamos os pesos pelo tempo de transição normalizado da origem para o destino (tempo gasto na transição). Com os dados preparados, o GPAS entra em jogo e conta as animações para cada entidade animada do jogo.

Os parâmetros de controle da unidade vêm da lógica de controle da unidade. Por exemplo, você precisa habilitar a corrida, definir o parâmetro CharSpeed ​​e uma máquina de estado configurada corretamente combina suavemente as animações de transição de "caminhada" para "corrida".

Naturalmente, a analogia completa com o Unity Animator não funcionou. Os princípios internos de trabalho, se não estiverem descritos na documentação, tiveram que ser revertidos e feito um análogo. Algumas funcionalidades ainda não foram concluídas (pode não ser). Por exemplo, o BlendType no BlendTree suporta apenas 1D. Fazer outros tipos, em princípio, não é difícil, só que agora não é necessário. Não há eventos de animação, pois é necessário fazer a leitura com a GPU, e a leitura "correta" ficará com vários quadros atrás, o que nem sempre é aceitável. Mas também é possível.

Render


A renderização da unidade é feita através da instanciação. De acordo com SV_InstanceID, no sombreador de vértices, obtemos a matriz de todos os ossos que afetam o vértice e o transformamos. Absolutamente nada de anormal:

 float4 ApplySkin(float3 v, uint vertexID, uint instanceID) { BoneInfoPacked bip = boneInfos[vertexID]; BoneInfo bi = UnpackBoneInfo(bip); SkeletonInstance skelInst = skeletonInstances[instanceID]; int bonesOffset = skelInst.boneOffset; float4x4 animMat = 0; for (int i = 0; i < 4; ++i) { float bw = bi.boneWeights[i]; if (bw > 0) { uint boneId = bi.boneIDs[i]; float4x4 boneMat = boneMatrices[boneId + bonesOffset]; animMat += boneMat * bw; } } float4 rv = float4(v, 1); rv = mul(rv, animMat); return rv; } 

Sumário


Este farm trabalha rápido? Obviamente, é mais lento do que amostrar a textura com matrizes, mas ainda assim, posso mostrar alguns números (GTX 970).

Aqui estão 50.000 máquinas de estado:



Aqui estão 280.000 ossos animados:



Projetar e depurar tudo isso é uma verdadeira dor. Um monte de buffers e compensações. Um monte de componentes e suas interações. Houve momentos em que as mãos caíram quando você bateu na cabeça sobre um problema por vários dias, mas não conseguiu encontrar qual é o problema. É especialmente "legal" quando tudo funciona como deveria nos dados de teste, mas em uma situação real de "combate" não há nenhum tipo de falha de animação. Discrepâncias entre a operação das máquinas de estado Unity e as suas próprias também não são imediatamente visíveis. Em geral, se você decidir fazer um analógico para si mesmo, não te invejo. Na verdade, todo o desenvolvimento da GPU é motivo para reclamar.

PS: Eu quero jogar uma pedra no jardim dos desenvolvedores do Unite TechDemo. Eles têm um grande número de modelos idênticos de ruínas e pontes no palco e não otimizaram sua renderização de forma alguma. Em vez disso, eles tentaram marcando "estático". Só agora, em índices de 16 bits, você não pode ter muita geometria (três vezes haha, 2017) e nada se juntou, já que os modelos são altamente poligonais. Coloquei "Ativar Instância" para todos os shaders e desmarcou "Estático". Não houve um impulso tangível, mas, droga, você está fazendo uma demo tecno, lutando por todos os FPS. Você não pode porcaria assim.

Was
 *** Summary *** Draw calls: 2553 Dispatch calls: 0 API calls: 8378 Index/vertex bind calls: 2992 Constant bind calls: 648 Sampler bind calls: 395 Resource bind calls: 805 Shader set calls: 682 Blend set calls: 230 Depth/stencil set calls: 92 Rasterization set calls: 238 Resource update calls: 1017 Output set calls: 74 API:Draw/Dispatch call ratio: 3.28163 298 Textures - 1041.01 MB (1039.95 MB over 32x32), 42 RTs - 306.94 MB. Avg. tex dimension: 1811.77x1810.21 (2016.63x2038.98 over 32x32) 216 Buffers - 180.11 MB total 17.54 MB IBs 159.81 MB VBs. 1528.06 MB - Grand total GPU buffer + texture load. *** Draw Statistics *** Total calls: 2553, instanced: 2, indirect: 2 Instance counts: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: >=15: ******************************************************************************************************************************** (2) 


Tornou-se
 *** Summary *** Draw calls: 1474 Dispatch calls: 0 API calls: 11106 Index/vertex bind calls: 3647 Constant bind calls: 1039 Sampler bind calls: 348 Resource bind calls: 718 Shader set calls: 686 Blend set calls: 230 Depth/stencil set calls: 110 Rasterization set calls: 258 Resource update calls: 1904 Output set calls: 74 API:Draw/Dispatch call ratio: 7.5346 298 Textures - 1041.01 MB (1039.95 MB over 32x32), 42 RTs - 306.94 MB. Avg. tex dimension: 1811.77x1810.21 (2016.63x2038.98 over 32x32) 427 Buffers - 93.30 MB total 9.81 MB IBs 80.51 MB VBs. 1441.25 MB - Grand total GPU buffer + texture load. *** Draw Statistics *** Total calls: 1474, instanced: 391, indirect: 2 Instance counts: 1: 2: ******************************************************************************************************************************** (104) 3: ************************************************* (40) 4: ********************** (18) 5: ****************************** (25) 6: ********************************************************************************************* (76) 7: *********************************** (29) 8: ************************************************** (41) 9: ********* (8) 10: ************** (12) 11: 12: ****** (5) 13: ******* (6) 14: ** (2) >=15: ****************************** (25) 


PPS Em todos os momentos, os jogos eram principalmente vinculados à CPU, ou seja, A CPU não acompanhou a GPU. Muita lógica e física. Transferindo parte da lógica do jogo da CPU para a GPU, descarregamos o primeiro e carregamos o segundo, ou seja, tornar a situação da GPU vinculada mais provável. Daí o título do artigo.

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


All Articles