Unity introdujo recientemente ECS. En el proceso de estudio, me interesé en cómo la animación y ECS pueden hacerse amigos. Y en el proceso de búsqueda, me encontré con una técnica interesante que los chicos de NORDVEUS usaron en su demo para el informe Unite Austin 2017.
Unite Austin 2017 - Batalla masiva en el universo Spellsouls.
El informe contiene muchas soluciones interesantes, pero hoy discutiremos la preservación de la animación esquelética en textura con miras a su posterior aplicación.
¿Por qué tantas dificultades, preguntas?
Los chicos de NORDVEUS pintaron simultáneamente en la pantalla una gran cantidad del mismo tipo de objeto animado: esqueletos, espadachines. En el caso de utilizar el enfoque tradicional: SkinnedMeshRenderers y Animation \ Animator , implicarán un aumento en las llamadas de extracción y una carga adicional en la CPU para renderizar la animación. Y para resolver estos problemas, la animación se movió al lado de la GPU y, más precisamente, al sombreador de vértices.
Estaba muy interesado en el enfoque y decidí resolverlo con más detalle, y como no encontré artículos sobre este tema, me metí en el código. En el proceso de estudiar el tema, nació este artículo y mi visión de resolver este problema.
Entonces cortemos el elefante en pedazos:
- Obtener claves de animación de clips
- Guardar datos en textura
- Preparación de malla (malla)
- Shader
- Poniendo todo junto
Obtener claves de animación de clips de animación
De los componentes de SkinnedMeshRenderers obtenemos una variedad de huesos y una malla. El componente Animación proporciona una lista de animaciones disponibles. Entonces, para cada clip, debemos guardar la matriz de transformación cuadro por cuadro para todos los huesos de la malla. En otras palabras, mantenemos la pose del personaje por unidad de tiempo.
Seleccionamos una matriz bidimensional en la que se almacenarán los datos. Una de las dimensiones tiene el número de fotogramas multiplicado por la longitud del clip en segundos. Otro es el número total de huesos en la malla:
var boneMatrices = new Matrix4x4[Mathf.CeilToInt(frameRate * clip.length), renderer.bones.Length];
En el siguiente ejemplo, cambiamos los cuadros para el clip uno por uno y guardamos las matrices:
Las matrices son de 4 por 4, pero la última fila siempre se ve (0, 0, 0, 1). Por lo tanto, con el propósito de una ligera optimización, se puede omitir. Lo que a su vez reducirá el costo de la transferencia de datos entre el procesador y la tarjeta de video.
a00 a01 a02 a03 a10 a11 a12 a13 a20 a21 a22 a23 0 0 0 1
Guardar datos en textura
Para calcular el tamaño de la textura, multiplicamos el número total de cuadros en todos los clips de animación por el número de huesos y el número de filas en la matriz (acordamos que guardamos las primeras 3 filas).
var dataSize = numberOfBones * numberOfKeyFrames * MATRIX_ROWS_COUNT);
Escribimos los datos en la textura. Para cada clip, guardamos la matriz de transformación cuadro por cuadro. El formato de datos es el siguiente. Los clips se graban secuencialmente uno por uno y consisten en un conjunto de cuadros. Que a su vez consisten en un conjunto de huesos. Cada hueso contiene 3 filas de matriz.
Clip0[Frame0[Bone0[row0,row1,row2]...BoneN[row0,row1,row2].]...FramM[bone0[row0,row1,row2]...ClipK[...]
El siguiente es el código de guardar:
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);
Preparación de malla (malla)
Agregue un conjunto adicional de coordenadas de textura en las que para cada vértice guardamos los índices óseos asociados y el peso de la influencia del hueso en este vértice.
Unity proporciona una estructura de datos en la que son posibles hasta 4 huesos para un vértice. A continuación se muestra el código para escribir estos datos en uv. Guardamos los índices óseos en UV1, los pesos en 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
La tarea principal del sombreador es encontrar la matriz de transformación para el hueso asociado con el vértice y multiplicar las coordenadas del vértice por esta matriz. Para hacer esto, necesitamos un conjunto adicional de coordenadas con índices y pesos óseos. También necesitamos el índice de la trama actual, cambiará con el tiempo y se transmitirá desde la CPU.
Entonces obtuvimos el índice de la primera fila de la matriz, el índice de la segunda y tercera será +1, +2, respectivamente. Queda por traducir el índice unidimensional en las coordenadas normalizadas de la textura y para esto necesitamos el tamaño de la 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); }
Después de restar las filas, recolectamos la matriz sin olvidar la última fila, que siempre es 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);
Al mismo tiempo, varios huesos pueden afectar un vértice a la vez. La matriz resultante será la suma de todas las matrices que afectan el vértice multiplicado por el peso de su influencia.
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;
Habiendo recibido la matriz, la multiplicamos por las coordenadas del vértice. Por lo tanto, todos los vértices se moverán a la pose del personaje, que corresponde al cuadro actual. Cambiando el marco, animaremos al personaje.
Poniendo todo junto
Para mostrar objetos, usaremos Graphics.DrawMeshInstancedIndirect, al cual transferiremos la malla y el material preparados. Además, en el material, debemos pasar la textura con animaciones al tamaño de la textura y una matriz con punteros al marco para cada objeto en el momento actual. Como información adicional, pasamos la posición para cada objeto y la rotación. Cómo cambiar la posición y la rotación en el lado del sombreador se puede encontrar en [artículo] .
En el método Actualizar, aumente el tiempo transcurrido desde el comienzo de la animación en Time.deltaTime.
Para calcular el índice de fotogramas, debemos normalizar el tiempo dividiéndolo por la longitud del clip. Por lo tanto, el índice de fotogramas en el clip será el producto del tiempo normalizado por el número de fotogramas. Y el índice de cuadro en la textura será la suma del desplazamiento del comienzo del clip actual y el producto del cuadro actual por la cantidad de datos almacenados en este cuadro.
var offset = clipStart + frameIndex * bonesCount * 3.0f
Probablemente todo eso haya pasado todos los datos al sombreador, llamamos Graphics.DrawMeshInstancedIndirect con la malla y el material preparados.
Conclusiones
Probar esta técnica en una máquina con una tarjeta gráfica 1050 mostró un aumento de rendimiento de aproximadamente 2 veces.

Animación de 4000 objetos del mismo tipo en la CPU

Animación de 8000 objetos del mismo tipo en la GPU
Al mismo tiempo, probar esta escena en un macbook pro 15 con una tarjeta gráfica integrada muestra el resultado opuesto. La GPU pierde descaradamente (alrededor de 2-3 veces), lo cual no es sorprendente.
La animación en la tarjeta de video es otra herramienta que puede usarse en su aplicación. Pero como todas las herramientas, debe usarse con prudencia y fuera de lugar.
Referencias
[Código del proyecto GitHub]Gracias por su atencion
PD: Soy nuevo en Unity y no conozco todas las sutilezas, el artículo puede contener imprecisiones. Espero solucionarlos con su ayuda y comprender mejor el tema.