En este capítulo comenzamos a analizar el desarrollo de juegos en 3D utilizando MonoGame. Anteriormente llamé a XNA un motor centrado en código de bajo nivel y está a punto de entender por qué. Si vienes de un motor de juegos de nivel superior como Unity o incluso LibGDX, estás a punto de recibir una sorpresa. Las cosas que puede dar por sentadas en otros motores/bibliotecas, como las cámaras, son su responsabilidad en Monogame. Pero no te preocupes, no es tan difícil.
Esta información también es disponible en vídeo HD.
Este capítulo requerirá algo de experiencia matemática previa, como una comprensión de las matemáticas Matrix. Desafortunadamente, enseñar tales conceptos está mucho más allá del alcance de lo que podemos cubrir aquí sin agregar unos cientos de páginas más. Si necesita repasar las matemáticas subyacentes, la academia khan es un muy buen lugar para empezar. También hay algunos libros dedicados a la enseñanza de matemáticas relacionadas con el desarrollo de juegos, incluidos Introducción a las matemáticas 3D para gráficos y desarrollo de juegos y Matemáticas para la programación de juegos en 3D y gráficos por computadora. No se preocupe, Monogame/XNA le proporciona las clases Matrix y Vector, pero es bueno entender cuándo usarlas y por qué.
Nuestra primera aplicación 3D
Este podría ser uno de esos temas que se explican más fácilmente viendo. Entonces, comencemos con un ejemplo y sigamos con una explicación. Este ejemplo crea y luego muestra un triángulo simple sobre el origen, luego crea una cámara controlada por el usuario que puede orbitar y acercar/alejar dicho triángulo.
using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using Microsoft.Xna.Framework.Input; namespace Test3D { public class Test3DDemo : Game { GraphicsDeviceManager graphics; SpriteBatch spriteBatch; //Camera Vector3 camTarget; Vector3 camPosition; Matrix projectionMatrix; Matrix viewMatrix; Matrix worldMatrix; //BasicEffect for rendering BasicEffect basicEffect; //Geometric info VertexPositionColor[] triangleVertices; VertexBuffer vertexBuffer; //Orbit bool orbit = false; public Test3DDemo() { graphics = new GraphicsDeviceManager(this); Content.RootDirectory = "Content"; } protected override void Initialize() { base.Initialize(); //Setup Camera camTarget = new Vector3(0f, 0f, 0f); camPosition = new Vector3(0f, 0f, -100f); projectionMatrix = Matrix.CreatePerspectiveFieldOfView( MathHelper.ToRadians(45f), GraphicsDevice.DisplayMode.AspectRatio, 1f, 1000f); viewMatrix = Matrix.CreateLookAt(camPosition, camTarget, new Vector3(0f, 1f, 0f));// Y up worldMatrix = Matrix.CreateWorld(camTarget, Vector3. Forward, Vector3.Up); //BasicEffect basicEffect = new BasicEffect(GraphicsDevice); basicEffect.Alpha = 1f; // Want to see the colors of the vertices, this needs to be on basicEffect.VertexColorEnabled = true; //Lighting requires normal information which VertexPositionColor does not have //If you want to use lighting and VPC you need to create a custom def basicEffect.LightingEnabled = false; //Geometry - a simple triangle about the origin triangleVertices = new VertexPositionColor[3]; triangleVertices[0] = new VertexPositionColor(new Vector3( 0, 20, 0), Color.Red); triangleVertices[1] = new VertexPositionColor(new Vector3(- 20, -20, 0), Color.Green); triangleVertices[2] = new VertexPositionColor(new Vector3( 20, -20, 0), Color.Blue); //Vert buffer vertexBuffer = new VertexBuffer(GraphicsDevice, typeof( VertexPositionColor), 3, BufferUsage. WriteOnly); vertexBuffer.SetData<VertexPositionColor>(triangleVertices) ; } protected override void LoadContent() { spriteBatch = new SpriteBatch(GraphicsDevice); } protected override void UnloadContent() { } protected override void Update(GameTime gameTime) { if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed || Keyboard.GetState().IsKeyDown( Keys.Escape)) Exit(); if (Keyboard.GetState().IsKeyDown(Keys.Left)) { camPosition.X -= 1f; camTarget.X -= 1f; } if (Keyboard.GetState().IsKeyDown(Keys.Right)) { camPosition.X += 1f; camTarget.X += 1f; } if (Keyboard.GetState().IsKeyDown(Keys.Up)) { camPosition.Y -= 1f; camTarget.Y -= 1f; } if (Keyboard.GetState().IsKeyDown(Keys.Down)) { camPosition.Y += 1f; camTarget.Y += 1f; } if(Keyboard.GetState().IsKeyDown(Keys.OemPlus)) { camPosition.Z += 1f; } if (Keyboard.GetState().IsKeyDown(Keys.OemMinus)) { camPosition.Z -= 1f; } if (Keyboard.GetState().IsKeyDown(Keys.Space)) { orbit = !orbit; } if (orbit) { Matrix rotationMatrix = Matrix.CreateRotationY( MathHelper.ToRadians(1f)); camPosition = Vector3.Transform(camPosition, rotationMatrix); } viewMatrix = Matrix.CreateLookAt(camPosition, camTarget, Vector3.Up); base.Update(gameTime); } protected override void Draw(GameTime gameTime) { basicEffect.Projection = projectionMatrix; basicEffect.View = viewMatrix; basicEffect.World = worldMatrix; GraphicsDevice.Clear(Color.CornflowerBlue); GraphicsDevice.SetVertexBuffer(vertexBuffer); //Turn off culling so we see both sides of our rendered triangle RasterizerState rasterizerState = new RasterizerState(); rasterizerState.CullMode = CullMode.None; GraphicsDevice.RasterizerState = rasterizerState; foreach(EffectPass pass in basicEffect.CurrentTechnique. Passes) { pass.Apply(); GraphicsDevice.DrawPrimitives(PrimitiveType. TriangleList, 0, 3); } base.Draw(gameTime); } } }
Muy bien… esa es una muestra de código grande, pero no te preocupes, no es tan complicado. En un nivel superior, lo que hacemos aquí es crear un triángulo orientado sobre el origen. Luego creamos una cámara, compensada -100 unidades a lo largo del eje z pero mirando el origen. Luego respondemos al teclado, moviendo la cámara en respuesta a las teclas de flecha, acercando y alejando el zoom en respuesta a la tecla más y menos y alternando la órbita usando la barra espaciadora. Ahora echemos un vistazo a cómo logramos todo esto.
Primero, cuando dije que creamos una cámara, ese es un nombre inapropiado, de hecho, estamos creando tres Matrices diferentes (singular: Matrix), la matriz de Vista, Proyección y Mundo. Estas tres matrices se combinan para ayudar a posicionar elementos en el mundo de tu juego. Echemos un vistazo rápido a la función de cada uno.
Ver matriz La matriz de vista se utiliza para transformar las coordenadas del mundo al espacio de vista. Una forma mucho más fácil de visualizar la matriz de Vista es que representa la posición y orientación de la cámara. Se crea pasando la ubicación de la cámara, hacia donde apunta la cámara y especificando qué eje representa «Arriba» en el universo. XNA utiliza una orientación Y-arriba, que es importante tener en cuenta al crear modelos 3D. Blender por defecto trata a Z como el eje arriba/abajo, mientras que 3D Studio MAX usa el eje Y como «Arriba».
Matriz de proyección La matriz de proyección se utiliza para convertir el espacio de visualización 3D en 2D. En pocas palabras, esta es la lente de su cámara real y se crea especificando la llamada a CreatePerspectiveFieldOfView() o CreateOrthographicFieldOfView(). Con la proyección ortográfica, el tamaño de las cosas sigue siendo el mismo independientemente de su «profundidad» dentro de la escena. Para el renderizado en perspectiva, simula la forma en que funciona un ojo, haciendo que las cosas sean más pequeñas a medida que se alejan. Como regla general, para un juego 2D usas ortográfico, mientras que en 3D usas proyección en perspectiva. Al crear una vista en perspectiva, especificamos el campo de visión (piense en esto como los grados de visibilidad desde el centro de su vista), la relación de aspecto (las proporciones entre el ancho y el alto de la pantalla), plano cercano y lejano (mínimo y profundidad máxima para renderizar con cámara… básicamente el rango de la cámara). Todos estos valores van juntos para calcular algo llamado vista frustum, que se puede considerar como una pirámide en el espacio 3D que representa lo que está disponible actualmente.
Matriz mundial La matriz Mundo se utiliza para posicionar su entidad dentro de la escena. Esencialmente, esta es su posición en el mundo 3D. Además de la información posicional, la matriz Mundo también puede representar una orientación de objetos.
Entonces, en pocas palabras, una forma de pensarlo:
Ver matriz -> Ubicación de la cámara
Matriz de proyección -> Lente de la cámara
Matriz mundial –> Posición/Orientación del Objeto en Escena 3D
Al multiplicar estas tres Matrices, obtenemos la matriz WorldProjView, o un cálculo mágico que puede convertir un objeto 3D en píxeles.
¿Qué valor debo usar para el campo de visión??
Puede notar que en este ejemplo usé un valor relativamente pequeño de 45 grados en este ejemplo. Lo que puede preguntar es la configuración ideal para el campo de visión. Bueno, no hay uno, aunque hay algunos valores comúnmente aceptados. Los seres humanos generalmente tienen un campo de visión de unos 180 grados, pero esto incluye la visión periférica. Esto significa que si mantiene las manos estiradas, debería poder justo verlos fuera del borde de su visión. Básicamente, si está frente a ti, puedes verlo.
Sin embargo, los videojuegos, al menos sin tener en cuenta los juegos de auriculares VR, en realidad no utilizan los periféricos de su espacio visual. Los juegos de consola generalmente establecen un campo de visión de aproximadamente 60 grados, mientras que los juegos de PC a menudo establecen el campo de visión más alto, en el rango de 80 a 100 grados. La diferencia generalmente se debe al tamaño de la pantalla que se ve y la distancia desde ella. Cuanto mayor sea el campo de visión, más parte de la escena se representará en la pantalla.
A continuación tenemos el BasicEffect. ¿Recuerdas cómo antes usamos un SpriteBatch para dibujar sprites en la pantalla? Bueno, el BasicEffect es el equivalente en 3D. En realidad, es un contenedor sobre un sombreador HLSL responsable de mostrar las cosas en la pantalla. Ahora, la cobertura de HLSL está mucho más allá del alcance de lo que podemos cubrir aquí, pero básicamente son las instrucciones para las unidades de sombreado en su tarjeta gráfica que le dicen cómo renderizar las cosas. Aunque no puedo entrar en muchos detalles sobre cómo funciona HLSL, tiene suerte, ya que Microsoft lanzó el código de sombreado utilizado para crear BasicEffect en la muestra de Stock Effect disponible en http://xbox.create.msdn.com/en-US/education/catalog/sample/stock_effects. Para que BasicEffect funcione, necesita las matrices View, Projection y Matrix especificadas, afortunadamente acabamos de calcular las tres.
Finalmente, al final de Intialize(), creamos una matriz de VertexPositionColor, que puede suponer que es un vértice con datos de posición y color. Luego copiamos los datos del triángulo a un VertexBuffer usando una llamada a SetData(). Puede que estés pensando para ti mismo… WOW, ¿XNA no tiene primitivas simples como esta incorporadas? No, no es así, aunque hay ejemplos sencillos de la comunidad que puedes descargar, como este: http://xbox.create.msdn.com/en-US/education/catalog/sample/primitives_3d.
La lógica en Update() es bastante simple. Verificamos la entrada del usuario y respondemos en consecuencia. En el caso de que se presionen las teclas de flecha, o las teclas +/-, cambiamos la posición de la cámara. Al final de la actualización, volvemos a calcular la matriz de vista usando nuestra nueva posición de cámara. También en respuesta a la barra espaciadora, alternamos la órbita de la cámara y, si estamos orbitando, giramos la cámara otro grado con respecto al origen. Básicamente, esto muestra lo fácil que es actualizar la cámara cambiando viewMatrix. Tenga en cuenta que la matriz de proyección generalmente no se actualiza después de la creación, a menos que cambie la resolución.
Finalmente llegamos a nuestra llamada Draw(). Aquí configuramos la vista, la proyección y la matriz mundial de BasicEffect, limpiamos la pantalla, cargamos nuestro VertexBuffer en GraphicsDevice llamando a SetVertexBuffer(). A continuación, creamos un objeto RasterState y desactivamos la selección. Hacemos esto para no eliminar las caras, lo que haría que nuestro triángulo no fuera invisible cuando rotamos detrás de él. A menudo, en realidad desea eliminar caras, ¡no tiene sentido dibujar vértices que no son visibles! Finalmente, cargamos cada una de las Técnicas en BasicEffect (mire el archivo HLSL BasicEffect.fx y esto tendrá mucho más sentido. De lo contrario, permanezca atento cuando cubramos los sombreadores personalizados más adelante), finalmente dibujamos nuestros datos de triángulo para screen llamando a DrawPrimitives, en este caso es una TriangleList. Hay otras opciones, como líneas y tiras de triángulos, básicamente le está diciendo qué tipo de datos hay en VertexBuffer.
Lo admito, en comparación con muchos otros motores, ¡eso es muchísimo código para dibujar un triángulo en la pantalla! Sin embargo, la realidad es que generalmente escribes este código una vez y eso es todo. O trabaja a un nivel superior, como con modelos 3D importados mediante la canalización de contenido.
Cargar y mostrar modelos 3D
A continuación, echamos un vistazo al proceso de traer un modelo 3D desde una aplicación 3D, en este caso Blender. El proceso de creación de dicho modelo está mucho más allá del alcance de este tutorial, aunque he creado un video que muestra todo el proceso disponible aquí. O simplemente puede descargar el creado Lima y textura COLLADA.¿Qué formato de archivo funciona mejor?
La herramienta de canalización MonoGame se basa en una biblioteca subyacente llamada Assimp para cargar modelos 3D. Quizás se pregunte cuál de los muchos formatos de modelo compatibles debería usar si exporta desde Blender. FBX y COLLADA(dae) son los dos formatos más utilizados, mientras que X y OBJ a menudo se pueden utilizar de forma fiable con mallas no animadas muy sencillas. Dicho esto, exportar desde Blender siempre es complicado, y es una muy buena idea usar un visor como el que se incluye en el paquete convertidor FBX para verificar que su modelo exportado se vea correcto.
El video anterior también ilustra cómo agregar el modelo y la textura usando la canalización de contenido. No cubriré el proceso aquí, ya que funciona de manera idéntica a cuando usamos la canalización de contenido anteriormente. Vayamos directamente al código en su lugar:
using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using Microsoft.Xna.Framework.Input; namespace Test3D { public class Test3DDemo2 : Game { GraphicsDeviceManager graphics; SpriteBatch spriteBatch; //Camera Vector3 camTarget; Vector3 camPosition; Matrix projectionMatrix; Matrix viewMatrix; Matrix worldMatrix; //Geometric info Model model; //Orbit bool orbit = false; public Test3DDemo2() { graphics = new GraphicsDeviceManager(this); Content.RootDirectory = "Content"; } protected override void Initialize() { base.Initialize(); //Setup Camera camTarget = new Vector3(0f, 0f, 0f); camPosition = new Vector3(0f, 0f, -5); projectionMatrix = Matrix.CreatePerspectiveFieldOfView( MathHelper.ToRadians(45f), graphics. GraphicsDevice.Viewport.AspectRatio, 1f, 1000f); viewMatrix = Matrix.CreateLookAt(camPosition, camTarget, new Vector3(0f, 1f, 0f));// Y up worldMatrix = Matrix.CreateWorld(camTarget, Vector3. Forward, Vector3.Up); model = Content.Load<Model>("MonoCube"); } protected override void LoadContent() { spriteBatch = new SpriteBatch(GraphicsDevice); } protected override void UnloadContent() { } protected override void Update(GameTime gameTime) { if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed || Keyboard.GetState().IsKeyDown( Keys.Escape)) Exit(); if (Keyboard.GetState().IsKeyDown(Keys.Left)) { camPosition.X -= 0.1f; camTarget.X -= 0.1f; } if (Keyboard.GetState().IsKeyDown(Keys.Right)) { camPosition.X += 0.1f; camTarget.X += 0.1f; } if (Keyboard.GetState().IsKeyDown(Keys.Up)) { camPosition.Y -= 0.1f; camTarget.Y -= 0.1f; } if (Keyboard.GetState().IsKeyDown(Keys.Down)) { camPosition.Y += 0.1f; camTarget.Y += 0.1f; } if (Keyboard.GetState().IsKeyDown(Keys.OemPlus)) { camPosition.Z += 0.1f; } if (Keyboard.GetState().IsKeyDown(Keys.OemMinus)) { camPosition.Z -= 0.1f; } if (Keyboard.GetState().IsKeyDown(Keys.Space)) { orbit = !orbit; } if (orbit) { Matrix rotationMatrix = Matrix.CreateRotationY( MathHelper.ToRadians(1f)); camPosition = Vector3.Transform(camPosition, rotationMatrix); } viewMatrix = Matrix.CreateLookAt(camPosition, camTarget, Vector3.Up); base.Update(gameTime); } protected override void Draw(GameTime gameTime) { GraphicsDevice.Clear(Color.CornflowerBlue); foreach(ModelMesh mesh in model.Meshes) { foreach(BasicEffect effect in mesh.Effects) { //effect.EnableDefaultLighting(); effect.AmbientLightColor = new Vector3(1f, 0, 0); effect.View = viewMatrix; effect.World = worldMatrix; effect.Projection = projectionMatrix; } mesh.Draw(); } base.Draw(gameTime); } } }
Funciona de manera casi idéntica a cuando creamos el triángulo a mano, excepto que el modelo se carga mediante una llamada a Content.Load