Vamos aprender XNA – Parte 7 – Space Simulator!

Posted on junho 20, 2011 by

8


Olá pessoal, como estamos?

Já faz algum tempo que não escrevia nada sobre XNA aqui no blog. Hoje, retomo as atividades.

No post anterior, mostrei como carregar um modelo 3D simples. Agora, mostro como criar um pequeno “simulador de vôo”. Ou seja, nosso usuário poderá controlar o vôo de uma espaço-nave sobre um terreno “de outro mundo”.

image

O código de hoje é uma reconstrução de um exemplo encontrado em .

Como sempre, o código fonte está disponível em https://github.com/ElemarJR/VamosAprenderXNA. Recomendo fortemente que você baixe esse conteúdo e brinque com o nosso (pseudo)game. Fica mais fácil e mais interessante.

Modelos 3D necessários para esse projeto

Foram utilizados, nesse projeto, dois modelos 3D. Um para a nossa espaço-nave, outro para o piso. Uma curiosidade interessante é que o “quadriculado” que percebemos no piso é uma textura.

Checker

Importante destacar que o “mapeamento da textura” está documentado no arquivo gráfico para o piso. Assim sendo, toda a carga e aplicação fica por conta do XNA (não é necessário escrever código). Observe a estrutura do nosso projeto de conteúdo:

image

Como pode perceber, temos os dois modelos 3D e o arquivo com a textura (que a rigor, só precisa estar na mesma pasta do modelo 3D que o utiliza).

Isolando o código que representa os modelos 3D

Teremos uma espaço-nave e o piso sendo desenhadas constantemente. Como a lógica para desenhar esses dois objetos é praticamente idêntica, é conveniente isolar a lógica de manipulação e desenho desses modelos em uma classe especializada. Para o exemplo de hoje, criei uma classe chamada GameModel. Observe:


              
public class GameModel
{
    public Vector3 Position { get; set; }
    public Vector3 Rotation { get; set; }
    public Vector3 Scale { get; set; }

    public Vector3 BaseRotation { get; set;  }

    public Model Model { get; private set; }

    Matrix[] modelTransforms;

    public GameModel(
        Model Model
        )
    {
        this.Model = Model;

        modelTransforms = new Matrix[Model.Bones.Count];
        Model.CopyAbsoluteBoneTransformsTo(modelTransforms);

        this.baseBoundingSphere = Model.ComputeBoundingSphere();

        this.Position = Vector3.Zero;
        this.Rotation = Vector3.Zero;
        this.Scale = Vector3.One;
    }

    public static implicit operator GameModel(Model @model)
    {
        return new GameModel(@model);
    }

    private BoundingSphere baseBoundingSphere;
    public BoundingSphere BoundingSphere
    {
        get
        {
            return baseBoundingSphere.Transform(
                ComputeWorld()
                );
        }
    }

    Matrix ComputeWorld()
    {
        return
            Matrix.CreateFromYawPitchRoll(
                BaseRotation.Y,
                BaseRotation.X,
                BaseRotation.Z
                ) *
            Matrix.CreateScale(Scale) *
            Matrix.CreateFromYawPitchRoll(
                Rotation.Y,
                Rotation.X,
                Rotation.Z
                ) *
            Matrix.CreateTranslation(Position);
    }

    public void Draw(Matrix View, Matrix Projection)
    {
        Matrix baseWorld = ComputeWorld();

        foreach (ModelMesh mesh in Model.Meshes)
        {
            Matrix localWorld = modelTransforms[mesh.ParentBone.Index]
                * baseWorld;
            mesh.SetupEffects(localWorld, View, Projection);
            mesh.Draw();
        }
    }
}

            

Algumas observações importantes nesse código:

  • adicionei um conversor implícito do tipo Model (da infra do XNA, que representa o objeto 3D processado pela Content-pipeline) para GameModel. O objetivo é permitir um código cliente mais limpo.
  • mantenho um registro interno da posição, rotação e escala para o objeto quando este tiver que ser denhado no ambiente 3D. Perceba que essas propriedades são get/set pois desejo permitir que a posição/rotação/escala dos objetos seja alterada durante a execução;
  • calculo uma bounding sphere (esfera de limites). Objetos “bounding” (limites) são uteis para determinar colisão e, no post de hoje, para determinar se um objeto deverá ou não ser desenhado.
  • se compara com o código do post anteiror, perceberá que a rotina Draw parece mais simples. Isso ocorre porque coloquei o processamento dos effects em extension methods.

Observe os métodos de extensão implementados para configuração dos effects (arquivo ModelExtensions.cs).


              
public static BasicEffect SetWorld(
    this BasicEffect that,
    Matrix world
        )
{
    that.World = world;
    return that;
}

public static BasicEffect SetView(
    this BasicEffect that,
    Matrix view
        )
{
    that.View = view;
    return that;
}

public static BasicEffect SetProjection(
    this BasicEffect that,
    Matrix projection
        )
{
    that.Projection = projection;
    return that;
}

public static ModelMesh SetupEffects(this ModelMesh mesh,
    Matrix world,
    Matrix view,
    Matrix projection
        )
{
    foreach (var meshPart in mesh.MeshParts)
        ((BasicEffect)meshPart.Effect)
            .SetWorld(world)
            .SetView(view)
            .SetProjection(projection)
            .EnableDefaultLighting();

    return mesh;
}

            

Bounding objects

Uma atividade muito comum, quando estamos implementando ambientes com controle espacial é a detecção dos limites dos objetos que estamos desenhando. Esses limites são utilizados no cálculo de colisão. XNA fornece um conjunto de classes/métodos que facilitam a detecção desses limites.

Se observar o código que escrevemos para representar um modelo 3D, irá perceber que disponibilizo um objeto do tipo “BoundingSphere”. Trata-se uma esfera imaginária que “contém” o objeto 3D.  Para calcular a bounding sphere de um objeto, criei o seguinte método de extensão:


              
public static BoundingSphere ComputeBoundingSphere(
    this Model that
    )
{
    var result = new BoundingSphere(Vector3.Zero, 0);

    var modelTransforms = new Matrix[that.Bones.Count];
    that.CopyAbsoluteBoneTransformsTo(modelTransforms);

    foreach (ModelMesh mesh in that.Meshes)
    {
        BoundingSphere transformed = mesh.BoundingSphere.Transform(
            modelTransforms[mesh.ParentBone.Index]);

        result = result.MergeWith(transformed);
    }
    return result;
}

public static BoundingSphere MergeWith(
    this BoundingSphere first,
    BoundingSphere second
    )
{
    return BoundingSphere.CreateMerged(first, second);
}

            

O primeiro método percorre todas as malhas que constituem o modelo 3D combinando suas bounding spheres em uma só. O segundo melhora a semântica do código.

Uma câmera que persegue …

Até aqui, todas as nossas câmeras eram estáticas. Hoje, quero criar uma câmera que dê a sensação de estar perseguindo o objeto (você só vai notar o efeito baixando o código-fonte e executando o programa).

Para começar, vamos implementar uma câmera que faça só o básico (calcular as matrizes Projection e View). Observe:


              
public abstract class Camera
{
    Matrix view;
    Matrix projection;
    protected GraphicsDevice GraphicsDevice;

    public Matrix Projection
    {
        get { return projection; }
        protected set
        {
            projection = value;
            ComputeFrustum();
        }
    }

    public Matrix View
    {
        get { return view; }
        protected set
        {
            view = value;
            ComputeFrustum();
        }
    }

    public BoundingFrustum Frustum { get; private set; }

    public Camera(GraphicsDevice graphicsDevice)
    {
        this.GraphicsDevice = graphicsDevice;
        ComputePerspectiveProjectionMatrix(MathHelper.PiOver4);
    }

    private void ComputePerspectiveProjectionMatrix(float fieldOfView)
    {
        PresentationParameters pp = GraphicsDevice.PresentationParameters;

        float aspectRatio =
            (float)pp.BackBufferWidth /
            (float)pp.BackBufferHeight;

        this.Projection = Matrix.CreatePerspectiveFieldOfView(
            fieldOfView, aspectRatio, 0.1f, 1000000.0f);
    }

    public virtual void Update()
    {
    }

    private void ComputeFrustum()
    {
        Matrix viewProjection = View * Projection;
        Frustum = new BoundingFrustum(viewProjection);
    }

    public bool IsInView(BoundingSphere sphere)
    {
        return (Frustum.Contains(sphere) != ContainmentType.Disjoint);
    }

    public bool IsInView(BoundingBox box)
    {
        return (Frustum.Contains(box) != ContainmentType.Disjoint);
    }
}

            

A novidade dessa câmera é a presença do cálculo para BoundingFrustum. Na prática, esse objeto representa os “limites” da área desenhada (visível) para a câmera. Qualquer objeto dentro desses limites é visível para o usuário, qualquer objeto fora, não é. Logo, não deveria ser processado.

Os métodos IsInView servem para verificar essa “colisão”.

Lindo! Agora, vejamos como implementar nossa câmera que persegue:


              
public class ChaseCamera : Camera
{
    public Vector3 Position { get; private set; }
    public Vector3 Target { get; private set; }

    public Vector3 FollowTargetPosition { get; private set; }
    public Vector3 FollowTargetRotation { get; private set; }

    public Vector3 PositionOffset { get; set; }
    public Vector3 TargetOffset { get; set; }

    public Vector3 RelativeCameraRotation { get; set; }

    float springiness = .015f;

    public float Springiness
    {
        get { return springiness; }
        set { springiness = MathHelper.Clamp(value, 0, 1); }
    }

    public ChaseCamera(Vector3 PositionOffset, Vector3 TargetOffset,
        Vector3 RelativeCameraRotation, GraphicsDevice graphicsDevice)
        : base(graphicsDevice)
    {
        this.PositionOffset = PositionOffset;
        this.TargetOffset = TargetOffset;
        this.RelativeCameraRotation = RelativeCameraRotation;
    }

    public void Move(
        Vector3 newFollowTargetPosition,
        Vector3 newFollowTargetRotation
        )
    {
        this.FollowTargetPosition = newFollowTargetPosition;
        this.FollowTargetRotation = newFollowTargetRotation;
    }

    public void Rotate(Vector3 RotationChange)
    {
        this.RelativeCameraRotation += RotationChange;
    }

    public override void Update()
    {
        Vector3 combinedRotation = FollowTargetRotation +
            RelativeCameraRotation;

        Matrix rotation = Matrix.CreateFromYawPitchRoll(
            combinedRotation.Y, combinedRotation.X, combinedRotation.Z);

        Vector3 desiredPosition = FollowTargetPosition +
            Vector3.Transform(PositionOffset, rotation);

        Position = Vector3.Lerp(Position, desiredPosition, Springiness);

        Target = FollowTargetPosition +
            Vector3.Transform(TargetOffset, rotation);

        Vector3 up = Vector3.Transform(Vector3.Up, rotation);

        View = Matrix.CreateLookAt(Position, Target, up);
    }
}

            

Basicamente, essa classe mantem um registro de sua posição atual e sua posição desejada. A partir disso, a cada atualização, ela se desloca (lentamente [conforme propriedade Springness]) para a posição desejada. Uso um offset para facilitar a programação do jogo, uma vez que desejo especificar para câmera a mesma posição do objeto que estou persequindo. O efeito fica bacana.

A figura abaixo mostra nossa nave bem adiante .. e nossa câmera indo atrás

image

 

Implementando o Game e os controles

Já temos todos os elementos do Game. Agora, falta implementar a lógica principal e os controles. Veja como é simples:


              
public class Game1 : Microsoft.Xna.Framework.Game
{
    GraphicsDeviceManager graphics;
    SpriteBatch spriteBatch;

    ChaseCamera camera;

    readonly List models = new List();
    GameModel spaceship;

    public Game1()
    {
        graphics = new GraphicsDeviceManager(this);
        Content.RootDirectory = "Content";
    }

    protected override void LoadContent()
    {
        spriteBatch = new SpriteBatch(GraphicsDevice);

        spaceship = new GameModel(Content.Load("spaceship"))
        {
            Position = new Vector3(0, 400, 0),
            Scale = new Vector3(50f),
            BaseRotation = new Vector3(0, MathHelper.Pi, 0)
        };

        models.Add(spaceship);
        models.Add(Content.Load("ground"));

        camera = new ChaseCamera(
            new Vector3(0, 400, 1500),
            new Vector3(0, 200, 0),
            new Vector3(0, 0, 0),
            GraphicsDevice);
    }

    protected override void UnloadContent()
    {
    }

    // Called when the game should update itself
    protected override void Update(GameTime gameTime)
    {
        UpdateModel(gameTime);
        UpdateCamera(gameTime);

        base.Update(gameTime);
    }

    void UpdateModel(GameTime gameTime)
    {
        KeyboardState keyState = Keyboard.GetState();

        Vector3 rotChange = new Vector3(0, 0, 0);

        if (keyState.IsKeyDown(Keys.PageUp))
            rotChange += new Vector3(1, 0, 0);

        if (keyState.IsKeyDown(Keys.PageDown))
            rotChange += new Vector3(-1, 0, 0);

        if (keyState.IsKeyDown(Keys.Left))
            rotChange += new Vector3(0, 1, 0);

        if (keyState.IsKeyDown(Keys.Right))
            rotChange += new Vector3(0, -1, 0);

        spaceship.Rotation += rotChange * .025f;

        if (!keyState.IsKeyDown(Keys.Up) &&
            !keyState.IsKeyDown(Keys.Down))
            return;

        Matrix rotation = Matrix.CreateFromYawPitchRoll(
            spaceship.Rotation.Y, spaceship.Rotation.X, spaceship.Rotation.Z
            );

        spaceship.Position +=
            Vector3.Transform(
                keyState.IsKeyDown(Keys.Up) ? Vector3.Forward : Vector3.Backward,
                rotation
                )
            * (float)gameTime.ElapsedGameTime.TotalMilliseconds * 4;
    }

    void UpdateCamera(GameTime gameTime)
    {
        KeyboardState keyState = Keyboard.GetState();

        if (keyState.IsKeyDown(Keys.W))
            camera.PositionOffset += Vector3.Forward * 10;

        if (keyState.IsKeyDown(Keys.S))
            camera.PositionOffset += Vector3.Backward * 10;

        if (keyState.IsKeyDown(Keys.A))
            camera.PositionOffset += Vector3.Left * 10;

        if (keyState.IsKeyDown(Keys.D))
            camera.PositionOffset += Vector3.Right * 10;

        ((ChaseCamera)camera).Move(
            spaceship.Position,
            spaceship.Rotation
            );

        camera.Update();
    }

    protected override void Draw(GameTime gameTime)
    {
        GraphicsDevice.Clear(Color.Black);

        foreach (GameModel model in models)
            if (camera.IsInView(model.BoundingSphere))
                model.Draw(camera.View, camera.Projection);

        base.Draw(gameTime);
    }
}

            

Faço a carga da nave e do piso. Perceba como a carga do piso é simples pois o converter dispensa a necessidade de fazer referência direta a GameModel.

Quando carrego a nave, especifico parâmetros de escala e rotação original (não queria e nem tentei editar o modelo 3D original).

Em todo Update, verifico as teclas pressionadas. Onde:

  • reconheço as setas para deslocar a nave;
  • reconheço PgUp e PgDown para mudar a inclinação da nave;
  • reconheço as teclas W, S, A e D para alterar a posição relativa da câmera com relação a nave;

No método Draw, percorro a lista de modelos 3D que estão carregados (2, no nosso exemplo) verificando se eles estão “visíveis”. Em caso positivo, peço para que eles se desenhem.

Por hoje, era isso.

Smiley piscando

Posted in: 3D, C#, XNA