GPU enlazado. Cómo transferir todo a la tarjeta de video y un poco más. Animaciones

Érase una vez, fue un gran evento cuando una unidad de texturización múltiple o transformación e iluminación de hardware (T&L) apareció en la GPU. Establecer una tubería de función fija fue un chamanismo mágico. Y aquellos que sabían cómo habilitar y usar las funciones avanzadas de chips específicos a través de los hacks API D3D9, se consideraban que habían aprendido Zen. Pero el tiempo pasó, aparecieron sombreadores. Al principio, muy limitado tanto en funcionalidad como en longitud. Además, más y más funciones, más instrucciones, más velocidad. Apareció Compute (CUDA, OpenCL, DirectCompute), y el alcance de las capacidades de las tarjetas de video comenzó a expandirse rápidamente.

En esta serie de (con suerte) artículos, intentaré explicar y mostrar cómo "inusualmente" puede aplicar las capacidades de la GPU moderna al desarrollar juegos, además de los efectos gráficos. La primera parte estará dedicada al sistema de animación. Todo lo que se describe se basa en la experiencia práctica, implementado y funciona en proyectos de juegos reales.

Oooo, de nuevo la animación. Sobre esto cien veces ya escrito y descrito. ¿Qué es tan complicado? Empacamos la matriz de huesos en el búfer / textura, y la usamos para desollar en el sombreador de vértices. Esto se describió en las GPU Gems 3 (Capítulo 2. Representación animada de multitudes) . E implementado en la reciente presentación de Unite Tech . ¿Es posible de otra manera?

Technodemka de Unity


Mucha publicidad, pero ¿es realmente genial? Hay un artículo en el centro que describe en detalle cómo se realiza y funciona la animación esquelética en esta tecnodemo. El trabajo paralelo es todo bueno, no los consideramos. Pero necesitamos averiguar qué y cómo hay, en términos de representación.

En una batalla a gran escala, dos ejércitos luchan, cada uno compuesto por un tipo de unidad. Esqueletos a la izquierda, caballeros a la derecha. La variedad es regular. Cada unidad consta de 3 LOD (~ 300, ~ 1000, ~ 4000 vértices cada uno), y solo 2 huesos afectan el vértice. El sistema de animación consta de solo 7 animaciones para cada tipo de unidad (recuerdo que ya hay 2 de ellas). Las animaciones no se mezclan, pero cambian discretamente del código simple que se ejecuta en job'ax, que se enfatiza en la presentación. No hay máquina de estados. Cuando tenemos dos tipos de malla, puede atraer a toda la multitud en dos llamadas de sorteo instantaneas. La animación esquelética, como ya escribí, se basa en la tecnología descrita en 2009.
Innovador? Hmm ... un gran avance? Um ... ¿Adecuado para juegos modernos? Bueno, tal vez, la relación de FPS a la cantidad de unidades se jacta.

Las principales desventajas de este enfoque (matriz previa en texturas):

  1. Velocidad de fotogramas dependiente. Quería el doble de cuadros de animación: da el doble de memoria.
  2. Falta de animaciones combinadas. Puede hacerlos, por supuesto, pero en el sombreador de la piel se formará un desorden complejo a partir de la lógica de fusión.
  3. Falta de enlace a la máquina de estado de Unity Animator. Una herramienta conveniente para personalizar el comportamiento del personaje, que se puede conectar a cualquier sistema de diseño, pero en nuestro caso, debido al punto 2, todo se vuelve muy difícil (imagine cómo mezclar el BlendTree anidado).

GPAS


Sistema de animación con GPU. El nombre acaba de aparecer.
El nuevo sistema de animación tenía varios requisitos:

  1. Trabaja rápido (bueno, comprensible). Necesitas animar decenas de miles de unidades diferentes.
  2. Sea un análogo completo (o casi) del sistema de animación Unity. Si la animación se ve así, en el nuevo sistema debería verse exactamente igual. Posibilidad de cambiar entre sistemas integrados de CPU y GPU. Esto a menudo es necesario para la depuración. Cuando las animaciones son "defectuosas", al cambiar al animador clásico, puede comprender: estos son los problemas técnicos del nuevo sistema, o la máquina de estado / animación en sí.
  3. Todas las animaciones son personalizables en Unity Animator. Una herramienta conveniente, probada y, lo más importante, lista para usar. Construiremos bicicletas en otros lugares.

Replanteemos la preparación y elaboración de animaciones. No usaremos matrices. Las tarjetas de video modernas funcionan bien con bucles, admiten de forma nativa int además de flotante, por lo que trabajaremos con fotogramas clave como en una CPU.

Veamos un ejemplo de animación en el Visor de animación:



Se puede ver que los fotogramas clave se configuran por separado para posición, escala y rotación. Para algunos huesos, necesita muchos de ellos, para algunos solo unos pocos, y para aquellos huesos que no están animados por separado, solo se configuran los fotogramas clave iniciales y finales.

Posición - Vector3, quaternion - Vector4, escala - Vector3. La estructura de fotogramas clave tendrá una cosa en común (para simplificar), por lo que necesitamos 4 flotantes para adaptarse a cualquiera de los tipos anteriores. También necesitamos InTangent y OutTangent para la interpolación correcta entre fotogramas clave de acuerdo con la curvatura. Ah sí, y el tiempo normalizado no olvida:

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

Para obtener todos los fotogramas clave, use AnimationUtility.GetEditorCurve ().
Además, debemos recordar los nombres de los huesos, ya que será necesario reasignar los huesos de la animación en los huesos del esqueleto (y pueden no coincidir) en la etapa de preparación de los datos de la GPU.

Al llenar los buffers lineales con matrices de fotogramas clave, recordaremos los desplazamientos en ellos para encontrar aquellos que se relacionen con la animación que necesitamos.

Ahora interesante Animación esqueleto de GPU.

Preparamos un gran búfer ("número de esqueletos animados" X "número de huesos en el esqueleto" X "coeficiente empírico del número máximo de mezclas de animación"). En él almacenaremos la posición, rotación y escala del hueso en el momento de la animación. Y para todos los huesos animados planificados en este marco, ejecute el sombreador de cómputo. Cada hilo anima su hueso.

Cada fotograma clave, independientemente del tamaño al que pertenece (Traducir, Rotación, Escala), se interpola exactamente de la misma manera (búsqueda por búsqueda lineal, perdóname 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; } } 

La curva es una curva de Bezier cúbica, por lo que la función de interpolación será la siguiente:

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

Se calculó la postura local (TRS) del hueso. Luego, con un sombreador de cómputo separado, combinamos todas las animaciones necesarias para este hueso. Para hacer esto, tenemos un búfer con índices de animación y pesos de cada animación en la mezcla final. Obtenemos esta información de la máquina de estado. La situación de BlendTree dentro de BlendTree se resuelve de la siguiente manera. Por ejemplo, hay un árbol:



BlendTree Walk tendrá un peso de 0.35, Run - 0.65. En consecuencia, la posición final de los huesos debe estar determinada por 4 animaciones: Walk1, Walk2, Run1 y Run2. Sus pesos tendrán valores (0.35 * 0.92, 0.35 * 0.08, 0.65 * 0.92, 0.65 * 0.08) = (0.322, 0.028, 0.598, 0.052), respectivamente. Cabe señalar que la suma de los pesos siempre debe ser igual a uno, o se proporcionan errores mágicos.

El "corazón" de la función de fusión:

 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; 

Ahora puedes traducir a una matriz de transformación. Para Sobre la jerarquía de los huesos completamente olvidados.
En base a los datos del esqueleto, construimos una matriz de índices, donde la célula con el índice óseo contiene el índice de su padre. En la raíz, escribe -1.

Un ejemplo:



 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; 

Aquí, en principio, todos los puntos principales de renderizar y mezclar animaciones.

GPSM


GPU Powered State Machine (lo has adivinado bien). El sistema de animación descrito anteriormente funcionaría perfectamente con la Unidad de estado de animación de Unity, pero entonces todos los esfuerzos serían inútiles. Con la posibilidad de calcular decenas (si no cientos) de miles de animaciones por fotograma, UnityAnimator no extraerá miles de máquinas de estado que funcionen simultáneamente. Hmm ...
¿Qué es una máquina de estado en Unity? Este es un sistema cerrado de estados y transiciones, que se controla mediante propiedades numéricas simples. Cada máquina de estado funciona de manera independiente y para el mismo conjunto de datos de entrada. Espera un minuto ¡Esta es una tarea ideal para la GPU y los sombreadores de cómputo!

Fase de horneado

Primero, necesitamos recopilar y colocar todos los datos de la máquina de estado en una estructura amigable para GPU. Y esto: estados (estados), transiciones (transiciones) y parámetros (parámetros).
Todos estos datos se colocan en buffers lineales y se abordan por índices.
Cada hilo de cálculo considera su máquina de estados. AnimatorController proporciona una interfaz para todas las estructuras de máquinas de estado internas necesarias.

Las principales estructuras de la 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; }; 

  • El estado contiene la velocidad a la que se juega el estado y los índices de las condiciones para la transición a otros según la máquina de estados.
  • La transición contiene índices de estado "de" y "a". Tiempo de transición, tiempo de salida y un enlace a una serie de condiciones para entrar en este estado.
  • CurrentState es un bloque de datos de tiempo de ejecución con datos sobre el estado actual de la máquina de estados.
  • AnimationDef contiene una descripción de la animación con enlaces a otros relacionados con ella por BlendTree.
  • ParameterDef es una descripción del parámetro que controla el comportamiento de las máquinas de estado. Line0ab y Line1ab son los coeficientes de la ecuación de líneas para determinar el peso de la animación por el valor del parámetro. Desde aquí:


  • Condición: especificación de la condición para comparar el valor de tiempo de ejecución del parámetro y el valor de referencia.

Fase de tiempo de ejecución

El ciclo principal de cada máquina de estado se puede mostrar utilizando el siguiente algoritmo:



Hay 4 tipos de parámetros en Unity animator: float, int, bool y trigger (que es bool). Los presentaremos a todos como flotantes. Al configurar las condiciones, es posible elegir uno de los seis tipos de comparación. Si == es igual. IfNot == NotEqual. Por lo tanto, utilizaremos solo 4. El índice del operador se pasa al campo checkMode de la estructura Condición.

 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 comenzar la transición, todas las condiciones deben ser verdaderas. Las etiquetas de casos extraños son solo (int) AnimatorConditionMode. La lógica de interrupción es una lógica engañosa para interrumpir y revertir las transiciones.

Después de actualizar el estado de la máquina de estados y desplazar las marcas de tiempo en el marco delta t, es hora de preparar datos sobre qué animaciones deben leerse en este marco y los pesos correspondientes. Este paso se omite si el modelo de la unidad no está en el marco (Frustum descartado). ¿Por qué deberíamos considerar animaciones de lo que no es visible? Repasamos el estado de origen del árbol de mezclas, el estado de destino del árbol de mezclas, agregamos todas las animaciones de ellos y calculamos los pesos por el tiempo normalizado de transición de origen a destino (tiempo dedicado a la transición). Con los datos preparados, GPAS entra en juego y cuenta animaciones para cada entidad animada en el juego.

Los parámetros de control de la unidad provienen de la lógica de control de la unidad. Por ejemplo, debe habilitar la ejecución, establecer el parámetro CharSpeed ​​y una máquina de estado configurada correctamente combina sin problemas las animaciones de transición de "caminar" a "correr".

Naturalmente, la analogía completa con Unity Animator no funcionó. Los principios internos del trabajo, si no se describen en la documentación, tuvieron que revertirse y hacerse un análogo. Algunas funcionalidades aún no se han completado (puede que no sea así). Por ejemplo, BlendType en BlendTree solo admite 1D. Hacer otros tipos, en principio, no es difícil, ahora mismo no es necesario. No hay eventos de animación, ya que es necesario volver a leer con la GPU, y la lectura "correcta" tendrá varios fotogramas atrás, lo que no siempre es aceptable. Pero también es posible.

Renderizar


La representación de la unidad se realiza a través de instancias. De acuerdo con SV_InstanceID, en el sombreador de vértices, obtenemos la matriz de todos los huesos que afectan el vértice y lo transformamos. Absolutamente nada inusual:

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

Resumen


¿Esta granja funciona rápido? Obviamente más lento que muestrear la textura con matrices, pero aún así, puedo mostrar algunos números (GTX 970).

Aquí hay 50,000 máquinas de estado:



Aquí hay 280,000 huesos animados:



Diseñar y depurar todo esto es un verdadero dolor. Un montón de amortiguadores y compensaciones. Un montón de componentes y sus interacciones. Hubo momentos en que las manos cayeron cuando te golpeaste la cabeza por un problema durante varios días, pero no puedes encontrar cuál es el problema. Es especialmente "agradable" cuando todo funciona como debería en los datos de prueba, pero en una situación real de "combate" no hay ningún tipo de falla de animación. Las discrepancias entre el funcionamiento de las máquinas de estado de Unity y las suyas tampoco son visibles de inmediato. En general, si decides hacer un análogo para ti, no te envidio. En realidad, todo el desarrollo bajo la GPU es motivo de queja.

PD : Quiero tirar una piedra al jardín de los desarrolladores de Unite TechDemo. Tienen una gran cantidad de modelos idénticos de ruinas y puentes en el escenario, y no optimizaron su representación de ninguna manera. Por el contrario, lo intentaron marcando "estático". Pero ahora, en los índices de 16 bits no se puede acumular mucha geometría (tres veces jaja, 2017) y nada se ha unido, ya que los modelos son altamente poligonales. Puse "Activar Instancing" para todos los sombreadores y desmarqué "Estático". No hubo un impulso tangible, pero, maldita sea, estás haciendo una demo demo, luchando por cada FPS. No puedes cagar así.

Era
 *** 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) 


Se ha convertido
 *** 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 En todo momento, los juegos estaban principalmente vinculados a la CPU, es decir La CPU no siguió el ritmo de la GPU. Demasiada lógica y física. Al transferir parte de la lógica del juego de la CPU a la GPU, descargamos el primero y cargamos el segundo, es decir hacer que la situación de GPU sea más probable. De ahí el título del artículo.

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


All Articles