Optimizando un motor de videojuegos en C#

Hacia tiempo que no escribía un post y por fin he encontrado un tema interesante (al menos para mi). Desde hace 3 años trabajo en Plainconcepts desarrollando un motor multiplataforma de videojuegos llamada WaveEngine.

Todo el código que escribimos en WaveEngine debe en medida de lo posible ser lo más eficiente posible, ya que para que un videojuego corra a 60fps, todas las operaciones de IA, física, update y render de nuestro juego deben ejecutarse en como máximo 16 milliseconds. Como me decía Ignacio Castaño (una persona de la que aprendí muchísimo mientras estuve colaborando con NVidia) “lo difícil no es hacer las cosas (dentro del mundo de los videojuegos, efectos, graphics technique,…) lo difícil es conseguir que vayan rápidas”, lo cual suele marcar la diferencia entre desarrollos profesionales y desarrollos amateur.

En un graphics engine siempre hay partes que se pueden optimizar y un pequeño cambio a bajo nivel puede provocar grandes resultado a alto nivel, es importante destacar aquí que cuanto menos milliseconds consuma el engine más milliseconds quedarán libres para el usuario de dicho engine.

Gran parte de WaveEngine ha sido escrito en C# por lo que esto te lleva a un escenario concreto, en el que lo interesante es analizar cuales son las formas más eficientes de escribir código C# para este tipo de software en determinados aspectos. En un graphics engine se utilizan muchísimas estructuras de datos como vectors, matrix, quaternions, colors, estructuras que se crean y se destruyen muy rápido, por este motivo se suelen usar structs en vez de class, para intentar en medida de lo posible que el GC esté saltando continuamente, ya que gran parte de las veces estas struct se crean directamente en el stack en vez de en el heap.

WaveEngine al ser un motor multiplataforma por debajo puede usar cualquier API de dibujado a bajo nivel, como DirectX u OpenGL, esto significa que ciertas estructuras de datos deberán ser transformadas antes de poder ser enviadas al driver de la GPU. Ya escribí anteriormente cómo podemos hacer un casting entre estructuras similares usando código unsafe para evitar tener que hacer new de una estructura nueva en dichas microoperaciones que se realizan durante el render.

 

Tras aquellas optimizaciones, que fueron útiles para los adaptadores que usaban directX, ahora la idea era intentar realizar una optimización similar pero para OpenGLES, en este caso las funciones de la API de OpenTK lo que necesita como parámetro no son struct propias sino array de floats. Por ejemplo, una instancia de la estructura WaveEngine.Common.Math.Matrix son 16 floats (64bytes), y lo que necesita la API de OpenTK es que le pasemos dicha información en forma de float[] matrix = new float[16]. Como el motor es multiplataforma no podemos cambiar las estructuras básicas que se usan, debemos hacer la conversión de tipos en los adaptadores, es decir cada adaptador es el encargado de transformar los datos a lo que necesite.

¿Cuál sería la primera implementación que se nos ocurre hacer para convertir Matrices 4×4?, pues crear un método extensor que realice la transformación.

public static float[] ToAdapterMatrix(this WaveEngine.Common.Math.Matrix mat)

{

    return new float[]

    {

        mat.M11,

        mat.M12,

        mat.M13,

        mat.M14,

        mat.M21,

        mat.M22,

        mat.M23,

        mat.M24,

        mat.M31,

        mat.M32,

        mat.M33,

        mat.M34,

        mat.M41,

        mat.M42,

        mat.M43,

        mat.M44

    };

}

 

A diferencia de las struct la ventaja aquí es que los arrays no se pasan como copia, la desventaja es que los arrays suelen crearse en el heap, por lo que el GC tendrá que trabajar mucho si estamos continuamente creando y destruyendo arrays.

Por este motivo la primera mejora que se nos puede ocurrir aquí, es intentar tener una serie de arrays cacheados desde la clase que use este método extensor, que podamos reutilizar una y otra vez, consiguiendo de esa forma eliminar que se haga un new de una array para cada llamada.

public static void ToAdapterMatrix1(this WaveEngine.Common.Math.Matrix mat, float[] result)

{

    result[0] = mat.M11;

    result[1] = mat.M12;

    result[2] = mat.M13;

    result[3] = mat.M14;

    result[4] = mat.M21;

    result[5] = mat.M22;

    result[6] = mat.M23;

    result[7] = mat.M24;

    result[8] = mat.M31;

    result[9] = mat.M32;

    result[10] = mat.M33;

    result[11] = mat.M34;

    result[12] = mat.M41;

    result[13] = mat.M42;

    result[14] = mat.M43;

    result[15] = mat.M44;

}

Y la pregunta es, ¿podemos optimizarlo más?, la respuesta no es simple (si o no) ya que como veremos más adelante esto depende de la plataforma y del CLR del que dispongamos en ella.

Intentemos hacer algo similar al articulo anterior sobre conversiones de tipo entre struct usando código unsafe. Haremos una prueba a ver si es posible, el objetivo es mapear los valores de una struct en un array. Podemos por ejemplo obtener el puntero a un array y usar un for para ir avanzando el puntero e ir copiando cada valor.

 

Hagamos un pequeño ejemplo:

float[] array = new float[16] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 };

Matrix m = new Matrix(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16);

 

unsafe

{

    Matrix* pmatrix = &m;

    float* pmf = (float*)(Matrix*)&m;

 

    for (int i = 0; i < 16; i++)

    {

        float v = (float)*(pmf + i);

        Console.WriteLine(v + " - " + array[i]);

    }

 

    fixed (float* p = array)

    {

        for (int i = 0; i < 16; i++)

        {

            *(p + i) = *(float*)(pmf + i);

        }

 

 

        for (int i = 0; i < 16; i++)

        {

            float v = (float)*(pmf + i);

            Console.WriteLine(v + " - " + array[i]);

        }

    }

}

 

Es importante destacar aquí el significado de la palabra fixed, la cual indica al GC que hemos obtenido un puntero al array y por lo tanto dentro del bloque fixed queremos que se evite la posibilidad de que el GC tras una pasada mueva dicho array a otra posición. Internamente el GC en las pasadas no solo tiene que compactar y liberar espacio sino también debe actualizar las referencias a las zonas de memoria donde hayan cambiado los objetos, arrays, etc.

Si ejecutamos dicho código podemos ver como el contenido de la matrix es volcado sobre los elementos del array.

image

 

Pero claro, realmente esto es lo mismo que teníamos en el método anterior, vamos copiando float a float desde la struct al array, lo ideal ya que estamos trabajando con punteros sería poder copiar el contenido completo en una sola instrucción, pero a la hora de asignar los valores el puntero que tenemos tiene 4 bytes (float*), necesitaríamos un puntero de 64Bytes de tamaño.

Pues sinceramente no sabía que esto se podía hacer hasta que no me surgió la necesidad de hacerlo XD:

float[] array = new float[16] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 };

Matrix m = new Matrix(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16);

 

unsafe

{

    Matrix* pmatrix = &m;

 

    fixed (float* p = array)

    {

        Matrix* marray = (Matrix*)p;

 

        *marray = *pmatrix;

    }

}

 

Ahora ya no necesitamos el bucle que copia los valores de float en float, directamente estamos moviendo los 64bytes de un sitio a otro y con una sola instrucción. Esto ha sido posible haciendo un doble casting entre punteros, es decir Matrix* marray = (Matrix*)(float*)array.

En el código anterior estamos usando dos punteros aunque solo uno esta fijado (fixed), el otro si es struct que se encuentra en el stack no sería necesario, pero para evitar posibles problemas podamos quitar dicho puntero y usar directamente m. Veamos como metemos todas las ideas dentro de un método extensor:

public static unsafe void ToAdapter(this WaveEngine.Common.Math.Matrix matrix, float[] result)

{

    fixed (float* p = result)

    {

        Matrix* marray = (Matrix*)p;

        *marray = matrix;

    }

}

 

Lo curioso aquí es ver cómo estamos realizando una asignación directa entre una struct y un array.

 

Después de todo este esfuerzo mental XD, la pregunta ahora es, ¿esto es más eficiente que lo anterior?, la lógica inicialmente nos dice si, pero con eso no nos vale, hay que hacer pruebas. Vamos a realizar un test simple de rendimiento, en el cual probemos varias opciones, por ejemplo:

 

  1. Versión inicial en la que se hacía un new en cada llamada.
    public static float[] ToAdapterMatrix(this WaveEngine.Common.Math.Matrix mat)

    {

        return new float[]

        {

            mat.M11,

            mat.M12,

            mat.M13,

            mat.M14,

            mat.M21,

            mat.M22,

            mat.M23,

            mat.M24,

            mat.M31,

            mat.M32,

            mat.M33,

            mat.M34,

            mat.M41,

            mat.M42,

            mat.M43,

            mat.M44

        };

    }

     
  2. Asignación de valores.
    public static void ToAdapterMatrix1(this WaveEngine.Common.Math.Matrix mat, float[] result)

    {

        result[0] = mat.M11;

        result[1] = mat.M12;

        result[2] = mat.M13;

        result[3] = mat.M14;

        result[4] = mat.M21;

        result[5] = mat.M22;

        result[6] = mat.M23;

        result[7] = mat.M24;

        result[8] = mat.M31;

        result[9] = mat.M32;

        result[10] = mat.M33;

        result[11] = mat.M34;

        result[12] = mat.M41;

        result[13] = mat.M42;

        result[14] = mat.M43;

        result[15] = mat.M44;

    }

  3. Asignación de valores pero le añadimos al método el atributo [MethodImpl(MethodImplOptions.AggressiveInlining)] para intentar que se haga Inline de dicho método (al estilo de C++).
    [MethodImpl(MethodImplOptions.AggressiveInlining)]

    public static void ToAdapterMatrix2(this WaveEngine.Common.Math.Matrix mat, float[] result)

    {

        result[0] = mat.M11;

        result[1] = mat.M12;

        result[2] = mat.M13;

        result[3] = mat.M14;

        result[4] = mat.M21;

        result[5] = mat.M22;

        result[6] = mat.M23;

        result[7] = mat.M24;

        result[8] = mat.M31;

        result[9] = mat.M32;

        result[10] = mat.M33;

        result[11] = mat.M34;

        result[12] = mat.M41;

        result[13] = mat.M42;

        result[14] = mat.M43;

        result[15] = mat.M44;

    }

  4. Versión usando código Unsafe.
    public static unsafe void ToAdapter(this WaveEngine.Common.Math.Matrix matrix, float[] result)

    {

        fixed (float* p = result)

        {

            Matrix* marray = (Matrix*)p;

            *marray = matrix;

        }

    }

  5. Código unsafe + inline.
    [MethodImpl(MethodImplOptions.AggressiveInlining)]

    public static unsafe void ToAdapter1(this WaveEngine.Common.Math.Matrix matrix, float[] result)

    {

        fixed (float* p = result)

        {

            Matrix* marray = (Matrix*)p;

     

            *marray = matrix;

        }

    }

 

Ya tenemos la casuística a probar, faltan los tests que lancen miles de veces estas operaciones para sacar valores en ms con los que podamos evaluar el rendimiento de cada estrategia.

La siguiente tabla la he podido crear con la ayuda de David Ávila (un compañero del equipo de Wave). Hemos medido el rendimiento de esto no solo en Windows, sino también en WindowsPhone, iOS y Android, estos dos últimos utilizando los compiladores de Xamarin.

results

 

Windows

En esta plataforma los resultados son algo sorprendentes. Lo más eficiente es hacer la asignación simple, por lo que podemos pensar que usar código unsafe no sale gratis, pero también es sorprendente ver como el atributo [MethodImpl(MethodImplOptions.AggressiveInlining)] no le sienta nada bien. También puede ser que la palabra fixed requiera una sincronización con el thread del GC y que en esto se nos vaya gran parte del tiempo.

 

iOS

Esta plataforma es posiblemente la más beneficiada tras esta investigación, el rendimiento usando código unsafe es casi 10 veces mejor. Es importante destacar aquí que el compilador en iOS de Xamarin utiliza AOT para transformar directamente a binario de la plataforma, motivo por el cual posiblemente pueda tomar ventaja cuando activamos unsafe.

 

Android

En esta plataforma el código unsafe tampoco ayuda demasiado. Quizás también se deba a que a diferencia de iOS, aquí si que se usa un CLR de mono.

 

Windows Phone

La primera pregunta que mucha gente podéis haceros es, pero ¿es posible usar código unsafe en Windows Phone 8?, ya que al crear un proyecto la opción para habilitar el código unsafe está deshabilitada y no puede ser modificada. Lo curioso es que si que nos permite añadir esto pero manualmente, modificando los csproj y añadiendo en cada plataforma de compilación <AllowUnsafeBlocks>true</AllowUnsafeBlocks>.

Parece que el CLR para ARM que ha hecho Microsoft en Windows Phone 8 tiene un comportamiento similar al de x86 a diferencia de las versiones anteriores, y por lo tanto la asignación simple es también la mejor opción.

 

 

Bueno pues esto ha sido todo, espero que todo esto os haya gustado tanto como a mi XD, cualquier comentario, opinión o interpretación distinta de los resultado siempre será bienvenida (nunca es mal día para aprender algo nuevo).