Managed DirectX. Introducción y Tutorial I.

Introducción

DirectX. Si has llegado a este artículo por que quieres programar tu propio Quake 4 en tus ratos libres deja que te saque de tu error, jamás vas a poder hacerlo. Antiguamente los juegos los programaban uno o dos programadores que trabajaban unos meses y conseguían crear un juego (mejor o peor). Actualmente los juegos los programan equipos relativamente grandes de personas con diversas especialidades (no hace falta solo programar sino también diseño gráfico, composiciones musicales, efectos de sonido, etc).

¿Entonces que voy a poder hacer? Bueno, quizá no consigas programar un juego de última generación pero quizá si puedas programar algún pequeño juego, conseguir un simulador físico convincente o sencillamente impresionar a las visitas (y de paso mejorar tu curriculum). Además programar gráficos en 3D es bastante gratificante (una vez las cosas van funcionando) puesto que el resultado es más "gráfico".

Managed DirectX se traduce como DirectX "Asistido" que me parece un termino muy feo en español así que simpre utilizaré el termino ingles que me suena mucho más apropiado.

DirectX es complicado y relativamente dificil de explicar, en este artículo vamos a empezar con unos cuantos conceptos generales y un ejemplo de código completo. Lo más normal suele ser empezar por el ejemplo más simple que hay, sin luces, ni meshes y sin tocar las matrices demasiado sin embargo yo prefiero poner un ejemplo completo con todas las cosas básicas y explicar (aunque algunas sea solo por encima) todas las cosas que se utilizan de forma que os familiariceis cuanto antes con los conceptos básicos.

DirectX vs OpenGL

¿Por que programar en DirectX en vez de en OpenGL? No hay ninguna razón especial exceptuando la comodidad. En C# es muy sencillo usar DirectX gracias a las extensiones incluidas en el SDK sin tener que importar librerias extrañas, en cambio para programar con C# en opengl hay que hacer más cosas (importar ciertas librerias, al menos la última vez que lo intente) pero eso es todo, las diferencias entre ambos son relativamente pequeñas (en las cosas básicas al menos) y si sabes programar usando las librerías de DirectX no te será muy dificil adaptarte a OpenGL (y viceversa). Si por alguna razón lo que estás buscando es un manual de opengl hay numeroso recursos en la página de <a href="http://nehe.gamedev.net/">Neon Helium</a> que tiene varias guías de OpenGL (en ingles eso si).

¿Por que C#?

Igual que en el caso anterior por una cuestión de comodidad. C# proporciona algunas facilidades al programador sobre C++ .NET como son el recolector de basura o la enorme librería de clases de .NET. Hay algunas comparativas de rendimiento entre C# y C++ con DirectX y el rendimiento del segundo es algo superior pero, para lo que nosotros vamos a poder hacer a nivel personal dicha diferencia de rendimiento casi no se va a notar (a parte del hecho de que generalmente las malas prácticas de programación son lo que más afecta al rendimiento de un programa).

Primeros pasos

En este primer tutorial vamos a empezar por crear lo típico, un cubo en pantalla al que haremos rotar sobre si mismo de forma que introduciremos algunos conceptos como el de mesh, el proceso de pintado o el de inicialización del DirectX.

El proceso de dibujado en MDX (Managed DirectX) es relativamente sencillo aunque puede realizarse de diversas formas (en este caso vamos a utilizar el evento OnPaint del formulario).

En general el procedimiento general sigue más o menos estos pasos:

  • Inicializar el DirectX
  • Realizar el pintado de la escena (en una tarea aparte, como parte de un bucle en el hilo principal de ejecución (o cualquier otro) o realizandolo en el evento OnPaint). Dentro del pintado lo más normal es hacer:
    • Limpiar el device (algo así como poner un lienzo nuevo)
    • Preparar las luces y las camaras
    • Preparar los materiales
    • Pintar los objetos de la escena

Managed vs Unmanaged DirectX

Con la versión nueve de DirectX Microsoft introdujo el concepto de Managed DirectX (DirectX asistido) frente a la programación directX tradicional.

Managed DirectX constituye una integración de la funcionalidad DirectX como parte de las librerías .NET (a través del SDK de DirectX) facilitando en cierto sentido ciertas de las operaciones más comunes (y proporcionando parte de las ventajas de .NET como la seguridad de ejecución). La mayor parte de las operaciones son muy similares.

Referencias

Para poder utilizar la funcionalidad de DirectX debemos referenciar las librerías de DirectX adecuadas. Existen varios namespaces con distinta funcionalidad (no es el objetivo de este tutorial hablar sobre assemblys y namespaces de forma que si no sabes lo que son echale un vistazo antes a este otro artículo de breve introducción a .NET).

Hay varias librerías dentro del namespace DirectX (Microsoft.DirectX, Microsoft.DirectX.DirectSound, Microsoft.DirectX.Direct3D) que encapsulan la funcionalidad equivalente a sus nombres. Para este tutorial vamos a utilizar tipos y clases ubicadas en Microsoft.DirectX (que contiene las clases comunes) así como en Microsoft.DirectX.Direct3D y Microsoft.DirectX.Direct3DX (está última nos proporciona la clase Mesh y varias clases derivadas) por lo que deberemos incluirlas como referencias en nuestro proyecto.

Inicializando el DirectX

Antes de poder utilizar las funciones de DirectX para dibujar objetos necesitaremos inicializarlo, que, en resumen, es equivalente a obtener un device context que es, por describirlo de alguna forma, el lienzo en el que pintaremos.

El device (traducido dispositivo) constituye una abstracción de manejador del dispositivo físico que realiza el dibujado (la tarjeta gráfica) que nos presenta una serie de interface que por debajo estarán implementados de una determina forma y usando las capacidades concretas de la tarjeta gráfica que haya debajo (esto es muy similar a los <a href="http://www.thealphasite.org/origenes-de-datos-abstractos">origenes abstractos de datos</a> pero a la inversa).

El constructor del device

La descripción del constructor de un dispositivo es:

public Device (
    int adapter,
    DeviceType deviceType,
    Control renderWindow,
    CreateFlags behaviorFlags,
    PresentParameters presentationParameters
)

  • adapter indica el adaptador físico del sistema sobre el que estará mapeado el device (cuando hay más de una tarjeta gráfica por ejemplo). Casi siempre suele ser 0 que es el dispositivo primario del sistema
  • deviceType indica el tipo de dispositivo que vamos a crear.
    • Hardware: Es el tipo de dispositivo más común que vamos a crear en estos tiempos que corren en los que casi todas las tarjetas gráficas tienen soporte hardware para directx. Indica que la funcionalidad DirectX que se dibuje en el dispositivo se realizará mediante hardware.
    • Software: Cuando especificamos este tipo de dispotivo indicamos que las llamadas de pintado al dispositivo se traducirán en sentencias de software. Esto implica que algunas llamadas, algunas funciones de DirectX cuya implementación es específica de hardware, no estarán disponibles.
    • Reference: Con este tipo de dispositivo todas las llamadas se traduciran a instrucciones básicas software soportadas por cualquier tarjeta. Este tipo de dispositivo es el más lento de los tres (incluido el dispositivo software) pero permite realizar cualquier llamada aunque no esté soportada por el hardware de la máquina (lo cual es su principal motivo de ser ya que nos permitirá probar nuestra aplicación en un hardware del que no disponemos).
  • renderWindow indica la ventana con la que se asociará el control. Puesto que la clase Window Form contiene un handle de ventana de windows, un form o cualquier de sus descientes (como por ejemplo un panel) pueden servirnos de argumento. Lo normal es usar un form.
  • behaviorParameters nos permite especificar distintos parametros sobre el comportamiento del device (como si utilizará procesador de vertices por software o hardware)
  • presentParameters nos permite especificar aspectos de presentación del dispositivo en pantalla (resolución, profundidad de color, si el device es de pantalla completa...)

El código de inicialización

Crear un device un procedimiento muy sencillo en Managed DirectX (aunque pueden introducirse más opciones de configuración, pero estas son las básicas).

public int InitDirect3D(Form dxForm)
{
  PresentParameters pParameters = new PresentParameters();
 
  pParameters.Windowed = true;         // Aplicación en ventana
  pParameters.SwapEffect = SwapEffect.Discard; // Descartar el BackBuffer

  // Creamos el device (dxDevice es un miembro privado)
  dxDevice = new Device(0, DeviceType.Hardware, dxForm,
                                       CreateFlags.SoftwareVertexProcessing, pParameters);

  // Hacer que solo se pinte en el evento OnPaint
  // para evitar pintados automáticos
  this.SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.Opaque,true);
}

El proceso de dibujado

Vamos a realizar el proceso de dibujado en el evento OnPaint del formulario, hay otras formas de realizar el pintado (bastante mejores) pero este metodo tiene la ventaja de ser sencillo.

Matrices y DirectX

Aunque no es totalmente necesario un conocimiento rudimentario de algebra es recomendable para entender mejor las transformaciones que se producen en DirectX. Hay numerosos parametros de configuración en el device que nos permiten configurar las luces, efectos de pixel shader, etc. Para este ejemplo nos interesa observar las tres matrices de transformación. La matriz de proyección, la matriz de vista y la matriz de mundo (Projection, View y World por sus nombre en inglés).

Estas tres matrices van a definir lo que dibujamos:

  • La matriz de proyección define la proyección de la camara que vamos, esto es, como en una camara de video ordinaria, la relación de aspecto.
  • La matriz de vista define donde esta situada la camara y hacia donde mira esta.
  • La matriz de mundo define donde estamos pintando. Esta parte es un poco más complicada, simplificando representa la posición del lienzo cuando pintamos, es decir, de alguna forma, si queremos pintar una linea torcida (por ejemplo) lo que hacemos es girar el lienzo el angulo que queramos y entonces pintar la linea, luego volvemos a girar el lienzo lo que queramos y pintamos el siguiente objeto.

Dibujando

El proceso de dibujado en directX es sencillo. Lo primero que debemos hacer es limpiar la escena, es decir, comenzar con un lienzo en blanco (o de hecho con el color de fondo que prefiramos), una vez limpiada la escena definimos la situación de las luces presentes en la escena (puesto que si no hay luces no se verá nada), por último definimos los objetos de nuestra escena.

La definición de dichos objetos deberemos realizarla entre dos llamadas a dos metodos de DirectX: BeginScene y EndScene. Además, en el código que sigue aparecen algunas variables que estan definidas como miembros privados como son el caso de la variable angle que es un float que representa el giro (rotación) actual de los dos cubos que vamos a dibujar, el dxDevice que lo habremos creado durante la inicialización del DirectX y por ultimo un boxMaterial que será el que nos permita definir el color de los cubos.

private void SetLights()
{
  // Que se haga la luz ;)
  dxDevice.Lights[0].Type = LightType.Point;
  dxDevice.Lights[0].Position = new Vector3(-3.0f,3.0f,7.0f);
  dxDevice.Lights[0].Diffuse = System.Drawing.Color.CadetBlue;
  dxDevice.Lights[0].Attenuation0 = 0.2f;
  dxDevice.Lights[0].Range = 1000.0f;
  dxDevice.Lights[0].Update();
  dxDevice.Lights[0].Enabled = true;
}

public DXForm()
{
  //
  // Necesario para admitir el Diseñador de Windows Forms
  //
  InitD3D();
  cubeMesh = Mesh.Box(dxDevice,2.0f,2.0f,2.0f);
}

private void DXForm_Paint(object sender, System.Windows.Forms.PaintEventArgs e)
{
  // Limpiar el device y el ZBuffer usando un color azulado
  dxDevice.Clear(ClearFlags.Target | ClearFlags.ZBuffer,
                          System.Drawing.Color.CadetBlue,
                           1.0f,0);
  // Definir la matriz de proyección
  dxDevice.Transform.Projection = Matrix.PerspectiveFovLH((float) Math.PI / 4,
                                                                                             this.Width / this.Height,1.0f,100.0f);
  // Definir la camara
  dxDevice.Transform.View = Matrix.LookAtLH(new Vector3(0.0f,0.0f,10.0f),
                                                                        new Vector3(0.0f,0.0f,0.0f),
                                                                        new Vector3(0.0f,1.0f,0.0f));
  // Ir rotando el primer cubo y situarlo en -2,0,0
  dxDevice.Transform.World = Matrix.RotationYawPitchRoll(this.angle, this.angle,this.angle) *
                                               Matrix.Translation(-2.0f,0.0f,0.0f);
  // Comenzamos a pintar la escena
  dxDevice.BeginScene();
  // Asignar un material a nuestro cubo (color blanco)
  boxMaterial.Ambient = Color.White;
  boxMaterial.Diffuse = Color.White;
  dxDevice.Material = boxMaterial;
  // Calcular las normales del cubo
  cubeMesh.ComputeNormals();
  // Dibujar el cubo
  cubeMesh.DrawSubset(0);
  // Ir rotando el segundo cubo y situarlo en 2,0,0
  dxDevice.Transform.World = Matrix.RotationYawPitchRoll(-this.angle,this.angle,this.angle) *
                                               Matrix.Translation(2.0f,0.0f,0.0f);
  // Asignar el material a nuestro segundo cubo (rojo)
  boxMaterial.Ambient = Color.Red;
  boxMaterial.Diffuse = Color.Red;
  dxDevice.Material = boxMaterial;
  cubeMesh.ComputeNormals();
  cubeMesh.DrawSubset(0);
  dxDevice.EndScene();
  // Dibujamos la escena, actualizamos el angulo e invalidamos para volver a pintar
  dxDevice.Present();
  this.angle += 0.01f;
  this.Invalidate();
}

El proceso de dibujado en DirectX

El proceso de dibujado en DirectX resulta poco intuitivo al principio. El proceso de dibujado que todos consideramos intuitivo cuando dibjuamos un cubo en una determinada posición es, sencillamente dibujarlo en dicha posición, es decir de alguna forma decir, el cubo va a estar en esta posición, esta girado este determinado ángulo y tiene este determinado color.

Sin embargo en DirectX las cosas no son así. Haciendo un simil podríamos decir que el device es nuestro conjunto de recursos que incluye el lienzo sobre el que dibujamos, nuestra paleta de materiales, algunos patrones de colores (texturas), las luces, las camaras... (la claqueta XD). Por otro lado estan los objetos que podemos dibujar, cubos, esferas, objetos dibujados vertice a vertice, diseños en formato .x (meshes creadas por ejemplo con otras aplicaciones de diseño 3D) y dichos objetos se dibujan de una forma un tanto peculiar.

Si queremos dibujar un cuadrado (por ser uno de los ejemplo más sencillos) cuyo centro este en el punto (0,0,0) (es decir en el centro justo de la pantalla) y de dos unidades de longitud, tendremos que situar sus vertices en las siguientes coordenadas. (-1,1,0) Arriba izquierda, (-1,-1.0) Abajo izquierda, (1,-1,0) Abajo derecha, (1,1,0) Arriba derecha, pero es como si nuestro "lápiz" tan solo pudiera poner o quitar un punto, es decir, podemos imaginar nuestro lápiz suspendido sobre el lienzo y totalmente inamobible de forma que lo único que podemos hacer es ponerlo sobre el lienzo para pintar o quitarlo.

Ante esta situación lo que realmente hacemos para pintar nuestro cuadrado, puesto que no podemos mover el lapiz es, sencillamente desplazar el lienzo, si queremos pintar en el punto (2,0,0) lo que realmente hacemos es mover el lienzo en la dirección contraria, es decir, movelo a (-2,0,0) de forma que el punto (2,0,0) queda justo debajo de nuestro hipotético pincel y entonces pintar. De la misma forma no es el objeto el que es de un determinado color sino que, cuando vamos a pintarlo, asignamos el material correspondiente a nuestro pincel y entonces pintamos (es como pintar con lapices de colores).

Nota: Esto es una forma de verlo que me ayuda a mi personalmente a entender mejor como funciona de forma que debe tomarse como tal y no como una descripción "técnica" del asunto.

El código paso a paso

  // Que se haga la luz ;)
  dxDevice.Lights[0].Type = LightType.Point;
  dxDevice.Lights[0].Position = new Vector3(-3.0f,3.0f,7.0f);
  dxDevice.Lights[0].Diffuse = System.Drawing.Color.CadetBlue;
  dxDevice.Lights[0].Attenuation0 = 0.2f;
  dxDevice.Lights[0].Range = 1000.0f;
  dxDevice.Lights[0].Update();
  dxDevice.Lights[0].Enabled = true;

Para poder ver algo en nuestra escena debemos tener al menos una luz. Las luces, al contrario que los objetos de la escena no es necesario definirlas cada vez que pintamos sino que se definen una sola vez y permanecen en la posición y de la forma definida mientras no los cambiemos. Las luces se definen mediante un array (Lights) de objetos de tipo Light,

  • Type. Define el tipo de luz que crearemos. Tenemos varios tipos, para este ejemplo la definimos como Point que representa el equivalente a una bombilla, una luz situada en un punto que irradia en todas direcciones.
  • Position. Un vector de tres dimensiones que identifica la luz.
  • Diffuse. El color difuso* de la luz, en este caso tiene un ligero tono azulado.
  • Range y Attenuation. Range define el rango de la luz, es decir, la distancia hasta la que ilumina, en este caso a más de 1000 unidades de la luz esta sencillamente desaparece. Attenuation indica la perdida de fuerza de la luz, el ritmo al que se va apagando conforme se aleja.
  • La llamada a update hace que los cambios que realizamos sean efectivos.
  • La propiedad enabled indica si la luz está activa o inactiva.

  // Limpiar el device y el ZBuffer usando un color azulado
  dxDevice.Clear(ClearFlags.Target | ClearFlags.ZBuffer,
                          System.Drawing.Color.CadetBlue,
                           1.0f,0);

Con la llamada a clear limpiamos la pantalla, todo lo que hubieramos pintado en el frame (fotograma) anterior se descarta. El primer parametro indica que es lo que queremos limpiar, en este caso el lienzo (target) y z-buffer (el buffer de profundidad). Estos dos parametros son necesarios practicamente siempre al comienzo del bucle de dibujado para limpiar tanto los objetos anteriormente dibujados como la información de profundidad de dichos objetos.

Una vez hecho esto comenzamos el dibujado de la escena.

  // Comenzamos a pintar la escena
  dxDevice.BeginScene();

// Definir la matriz de proyección
  dxDevice.Transform.Projection = Matrix.PerspectiveFovLH((float) Math.PI / 4,
                                                                                             this.Width / this.Height,1.0f,100.0f);
  // Definir la camara
  dxDevice.Transform.View = Matrix.LookAtLH(new Vector3(0.0f,0.0f,10.0f),
                                                                        new Vector3(0.0f,0.0f,0.0f),
                                                                        new Vector3(0.0f,1.0f,0.0f));
  // Ir rotando el primer cubo y situarlo en -2,0,0
  dxDevice.Transform.World = Matrix.RotationYawPitchRoll(this.angle, this.angle,this.angle) *
                                               Matrix.Translation(-2.0f,0.0f,0.0f);

Con estas llamadas ajustamos las distintas matrices de transformación. Para una visión detallada de lo que esto significa recomiendo que le echeis un vistazo al artículo el+Pipeline+de+DirectX. En lineas generales estamos ajustando tres "parametros" del device que nos servirán para ajustar como vemos la imagen.

El primero de ellos define la proyección de la vista, esto es equivalente a definir la persepectiva de la camara, también conocida como el FOV (Field of view, o campo de visión). Estos parametros van a definir si percibimos la imagen en isométricas, en caballeras, etc ... Si habeís visto las típicas peliculas en las que se ve un pasillo que de repente se alarga hasta llegar al infinito, ese efecto se consigue cambiando precisamente estos valores (pero en una camara de verdad). Lo más normal es dejarlos en los parametros de arriba que consisten en una vista en isométricas (no estoy totalmente totalmente seguro de esto, pero si bastante seguro xD). Se define mediante una llamada a la función estática PerspectiveFovLH de la clase matriz que viene a ser algo así como Matriz de perspectiva FOV para mano izquierda (LH = Left Hand)

El segundo parametro define nuestra camara. Para ello vamos a utilizar otra función estática de la clase Matriz llamada LookAtLH (mirar a, mano izquierda) al que le pasamos tres vectores. El primero indica la posición de la camara, es decir, el sitio donde esta ubicada la camara. El segundo indica la posición del objetivo de la camara, es decir, el lugar hacia el que mira la camara, el tercero indica la orientación de la camará, es decir, hacia donde apunta su parte de arriba. Para hacer un simil podríamos equipararlo a si tenemos la camara normal, cogida de lado, boca abajo ...

Para nuestro ejemplo definimos la camara mirando al centro del universo :) (0,0,0) y situada con un desplazamiento de 10 en el eje Z, es decir, teniendo en cuenta que el eje Z mide la profundidad y que negativo es hacia dentro y positivo hacia fuera, la estamos situando más o menos en frente del monitor, cerca de donde estamos sentados.

Por último definimos las matriz de mundo. Está matriz define el punto en el que dibujaremos nuestro objeto (el simil hecho anteriormente es más o menos adecuado, pero repito, para una explciación correcta mejor leeros el artículo el+Pipeline+de+DirectX. De esta forma formamos esa matriz mediante la combinación (multiplicación) de dos matrices distintas, una de rotación (que es lo que hace que roten nuestros cubos) y otra de traslación (que nos permite hacer que nuestros cubos aparezcan un poco más a la derecha o un poco más a la izquierda del centro de la pantalla). Esto deberemos hacerlo cada vez que definamos un objeto ya que es la que define la posición y orientación del objeto.

Una vez definidas nuestras mátrices es hora de pasar al dibujado real de los dos cubos.

  dxDevice.Material = boxMaterial;
  cubeMesh.ComputeNormals();
  cubeMesh.DrawSubset(0);

En primer lugar asignamos el material (el color en este caso) que queramos que tenga nuestro cubo al dxDevice (recordemos que esto es algo así como mojar el pincel).

Despues realizamos la acción de calcular las normales del objeto (que no explicaré en detalle aqui pero que es necesario realizarlo para que las luces incidan correctamente sobre el objeto).

Por último mediante la llamada al metodo DrawSubset(0) estamos pintando realmente el objeto en la pantalla con todos los parametros anteriormente definidos. Un objeto, un mesh, puede tener varios subset que definen cada uno de los grupos de caras que tienen atributos distintos (distintas texturas, distintas propiedades...), en este caso puesto que tenemos un cubo sencillo con un material plano tan solo tenemos un set.

Ahora volvemos a asignar la matriz de transformación para dibujar el segundo cubo repitiendo los pasos anteriores pero cambiando en la matriz de transformación la posición.

Por último llamamos al metodo endScene() para indicar que hemos acabo de dibujar la escena.

Notas finales

Realmente las transformaciones en las matrices de proyección, camara y mundo no tienen por que estar entre el beginScene y el endScene puesto que solo definen las transformaciones que se llevarán a cabo cuando se dibuje la escena. Especialmente cierto es en la camara puesto que si esta no se mueve no hay necesidad de reajustar la matriz (y de hecho es un gasto tonto de tiempo de CPU) y sobre todo en la matriz de proyección que, exceptuando los momentos en los que queramos hacer efectos con la camara, es dificil que tengamos necesidad de cambiarla.

Por otro lado, en el archivo adjunto hay algunas cosas más de las que se han indicado aqui (aunque no muchas), así que recomiendo abrirlo y echarle un vistazo detallado.

AdjuntoTamaño
DxCube.zip12.15 KB
9.83333
Average: 9.8 (12 votes)
Your rating: None