jueves, 2 de julio de 2009

“XNAVATARES” - PARTE 1

Al final de mi artículo "Avatars 101" mencioné que publicaría un ejemplo con un enfoque un tanto diferente a fin de dibujar las transiciones de los avatares.

Bueno, he decidido extender el ejemplo y partir el resultado final en una serie de, posiblemente, 3 o 4 artículos.

Para empezar, en este artículo nos centraremos en el código para las transiciones que prometí.

Para simplificar un poco la explicación, todo el código para dibujar los avatares se encuentra implementado en la clase Game (pero esto va a cambiar en la tercera parte de la serie).

Ok, comencemos ...

1. Creen un proyecto de juego de XNA GS en Visual Studio y llámenlo, digamos, "AvatarGame".

2. Incluyan los siguientes campos (en adición a los que son automáticamente generados para Uds. por XNA GS):

// We will use a font to draw statistics.
private SpriteFont font;
 
// These are needed to handle our avatar.
private AvatarDescription avatarDesc;
private AvatarRenderer avatarRenderer;
private AvatarAnimation currentAnimation, targetAnimation;
 
// Holds an array of all animations available for our avatar.
private AvatarAnimation[] animations;
 
// These are needed to handle transitions between animations.
private bool isInTransition, moveToNextAnimation, moveRandomly;
private float transitionProgress, transitionStep;
private Matrix[] transitionTransforms;
private int currentAnimationId;
private Random randomizer;
 
// These will help to detected pressed buttons.
private GamePadState currentGamePadState, lastGamePadState;

El código se explica solo. Básicamente, estamos agregando los campos básicos que necesitamos para dibujar un avatar más los de ayuda para manejar las transiciones.

3. El constructor:

/// <summary>
/// Initializes a new instance of the <see cref="AvatarGame"/> class.
/// </summary>
public AvatarGame()
{
    // These are implemented for you.
    graphics = new GraphicsDeviceManager( this );
    Content.RootDirectory = "Content";
 
    // Create and add the GamerServices component.
    Components.Add( new GamerServicesComponent( this ) );
 
    // Create the array that will hold the collection of animations.
    this.animations = new AvatarAnimation[ 30 ];
 
    // Create the array of matrices that will hold bone transforms for transitions.
    this.transitionTransforms = new Matrix[ 71 ];
 
    // Create the random-number generator.
    this.randomizer = new Random( DateTime.Now.Millisecond );
}

Como es habitual, Uds. deben crear los gestores y servicios que utilizarán para dibujar los avatares.

Pero por qué necesitamos también dos arreglos y un generador de números al azar? El último, en caso que quiéramos cambiar las animaciones sin un órden en particular.

Y los arreglos? Sigan leyendo …

4. Cargando contenido:

/// <summary>
/// LoadContent will be called once per game and is the place to load
/// all of your content.
/// </summary>
protected override void LoadContent()
{
    // Create a new SpriteBatch, which can be used to draw textures.
    spriteBatch = new SpriteBatch( GraphicsDevice );
 
    // Load a spritefont.
    this.font = this.Content.Load<SpriteFont>( "MainFont" );
 
    // For each position in the array.
    for ( int i = 0; i < this.animations.Length; i++ )
    {
        // Create and store the corresponding animation.
        this.animations[ i ] = new AvatarAnimation( (AvatarAnimationPreset)i );
    }
 
    // Create a random description (instead, you can try to get the 
    // description of a signedIn gamer).
    this.avatarDesc = AvatarDescription.CreateRandom();
 
    // Create the renderer with a standard loading effect.
    this.avatarRenderer = new AvatarRenderer( avatarDesc, true );
 
    // Just for fun, set the current animation, randomly.
    this.currentAnimationId = this.randomizer.Next( this.animations.Length );
    this.currentAnimation = this.animations[ this.currentAnimationId ];
 
    // Set the "World" value.
    this.avatarRenderer.World =
        Matrix.CreateRotationY( MathHelper.ToRadians( 180.0f ) );
 
    // Set the "Projection" value.
    this.avatarRenderer.Projection =
        Matrix.CreatePerspectiveFieldOfView(
            MathHelper.ToRadians( 45.0f ),
            this.GraphicsDevice.Viewport.AspectRatio,
            .01f,
            200.0f );
 
    // Set the "View" value.
    this.avatarRenderer.View =
        Matrix.CreateLookAt(
            new Vector3( 0, 1, 3 ),
            new Vector3( 0, 1, 0 ),
            Vector3.Up );
}

Como pueden ver, en este método poblamos el arreglo de animaciones con cada una de las animaciones por defecto para los avatares. La razón? Usamos este arreglo como una memoria intermedia a efectos de agilizar el proceso de búsqueda al momento de pasar de una animación a la siguiente.

Entonces, configuramos la transición inicial y establecemos los campos de visión y proyección (nota: ambas matrices están siempre “fijas” en este ejemplo).

No hay código significativo para los métodos Initialize y Unload, y por ende avancemos al método Update.

5. Actualizando el juego:

/// <summary>
/// Allows the game to run logic such as updating the world,
/// checking for collisions, gathering input, and playing audio.
/// </summary>
/// <param name="gameTime">Provides a snapshot of timing values.</param>
protected override void Update( GameTime gameTime )
{
    // Update the gamepad state for player one.
    this.lastGamePadState = this.currentGamePadState;
    this.currentGamePadState = GamePad.GetState( PlayerIndex.One );
 
    // Allow the game to exit.
    if ( this.currentGamePadState.Buttons.Back == ButtonState.Pressed )
    {
        this.Exit();
    }
 
    // Force moving to the next animation without waiting for the 
    // current animation to end.
    if ( this.currentGamePadState.Buttons.A == ButtonState.Pressed &&
        this.lastGamePadState.Buttons.A == ButtonState.Released &&
        !this.isInTransition )
    {
        this.moveToNextAnimation = true;
    }
 
    // Change the type of selection: ascending order or randomly.
    if ( this.currentGamePadState.Buttons.B == ButtonState.Pressed &&
        this.lastGamePadState.Buttons.B == ButtonState.Released )
    {
        this.moveRandomly = !this.moveRandomly;
    }
 
    // Is there any animation to update?
    if ( currentAnimation != null )
    {
        // Is the current animation just about to finish or forced to end?
        if ( !this.isInTransition &&
            this.targetAnimation == null &&
            ( this.currentAnimation.RemainingTime().TotalSeconds <= 1 ||
                this.moveToNextAnimation ) )
        {
            // Are we moving randomly?
            if ( this.moveRandomly )
            {
                // If so, select a new animation at random.
                this.currentAnimationId = this.randomizer.Next( this.animations.Length );
            }
            else
            {
                // If not, point to the next animation.
                this.currentAnimationId++;
 
                // Keep the id pointing to a valid position in the array.
                this.currentAnimationId %= this.animations.Length;
            }
 
            // Set the corresponding target animation.
            this.targetAnimation = this.animations[ this.currentAnimationId ];
        }
 
        // Has the animation reached its last frame? Or is it forced 
        // to change by the user?
        if ( this.currentAnimation.LastFrameReached() || this.moveToNextAnimation )
        {
            // If so, start by resetting this marker flag to false.
            this.moveToNextAnimation = false;
 
            // State that we will process a transition.
            this.isInTransition = true;
 
            // Make a copy of the readonly transforms to the transition array.
            this.currentAnimation.BoneTransforms.CopyTo( this.transitionTransforms, 0 );
 
            // Reset the current animation.
            this.currentAnimation.CurrentPosition = TimeSpan.Zero;
 
            // Set the target one as the current one.
            this.currentAnimation = this.targetAnimation;
            this.targetAnimation = null;
 
            // You can tweak this value to meet your game's need,
            // considering the lenght of your animations (it could vary).
            this.transitionStep = .5f;
        }
 
        // Update the current animation (in this example, there is no looping).
        this.currentAnimation.Update( gameTime.ElapsedGameTime, false );
 
        // Are we processing a transition?
        if ( this.isInTransition )
        {
            // If so, update the progress value.
            this.transitionProgress += this.transitionStep * (float)gameTime.ElapsedGameTime.TotalSeconds;
 
            // Is the progress below 100%?
            if ( transitionProgress < 1f )
            {
                // Calculate the proper bone transforms.
                this.CalculateTransition();
            }
            else
            {
                // When the progress reaches 100%, reset everything.
                this.isInTransition = false;
                this.transitionProgress = 0;
            }
        }
    }
 
    // As usual, call the base method.
    base.Update( gameTime );
}

En tiempo de ejecución, si pulsan el botón ‘A’ y no hay ningúna transición ejecutándose, obligan “al avatar” a comenzar la transición hacia la siguiente animación.

Pulsando el botón ‘B’, cambian la forma en la que se selecciona la próxima animación: en orden ascendente o aleatoriamente.

Noten que cuando la animación actual está por finalizar, entonces el juego selecciona la próxima animación, en el método de ordenamiento que el usuario tiene seleccionado actualmente.

Al alcanzase el último cuadro de la animación que se ejecuta (sin repetición) o el usuario fuerza la transición, entonces copiamos las matrices de transfomación de los huesos para la posición en la animación, retornamos la posición a cero y luego cambiamos la animaicón por la “objetivo”.

Uds. pueden preguntarse cuál es el propósito del campo “transitionStep”. Este factor establece el tiempo que dedicaremos para movernos de “la última” posición de la animación que finaliza a la posición “corriente” de la animaición que incia.

Por qué hacia la “corriente”? El comportamiento que decidí elegir para crear una sensación creíble para la transición fue evitar ir de “la última a la primera” y en cambio de "la última a la actual” (noten que la animación que inicia comenzará desde el momento que ejecutamos la transición de salida, y por ello la posición actual o corriente se modificará).

Por supuesto que pueden cambiar el mencionado comportamiento si prefieren un comportamiento que vaya “desde la posición final a la inicial” antes de ejecutar la animación entrante.

Llamamos al método “CalculateTransition”, por último, el cual explicaré luego del método “Draw”.

6. Dibujando al avatar:

/// <summary>
/// This is called when the game should draw itself.
/// </summary>
/// <param name="gameTime">Provides a snapshot of timing values.</param>
protected override void Draw( GameTime gameTime )
{
    // As usual, clear the backbuffer (or the current render target).
    GraphicsDevice.Clear( Color.CornflowerBlue );
 
    // Can we draw the avatar?
    if ( avatarRenderer != null && currentAnimation != null )
    {
        // If we can, is the animation in transition?
        if ( this.isInTransition )
        {
            // If so, draw it with the interpolated transforms.
            avatarRenderer.Draw(
                this.transitionTransforms,
                currentAnimation.Expression );
        }
        else
        {
            // If not, draw it with the actual transforms.
            avatarRenderer.Draw(
                this.currentAnimation.BoneTransforms,
                currentAnimation.Expression );
        }
    }
 
    // The following is used to show some statistics and other info
    // on screen. It can be omitted (or optimized).
    this.spriteBatch.Begin();
 
    // No need for further explanation.
    this.spriteBatch.DrawString(
        this.font,
        "Press 'A' to force changing animations or 'Back' to exit.",
        new Vector2( 50, 25 ),
        Color.White );
 
    // No need for further explanation.
    this.spriteBatch.DrawString(
        this.font,
        "Press 'B' to change the type of selection : " +
        ( this.moveRandomly ? "RANDOMLY" : "IN ASCENDING ORDER" )
        + ".",
        new Vector2( 50, 55 ),
        Color.White );
 
    // Draw the animation pointer, whether we are processing a transition and
    // the current transition time. Please notice that in this implementation
    // when the current animation is about to end (that is, 1 second or less),
    // the pointer "currentAnimationId" will change even if the animation is still
    // the same, so you will see a different number and name during 1 second or so.
    this.spriteBatch.DrawString(
        this.font,
        this.currentAnimationId + " : " +
            ( (AvatarAnimationPreset)this.currentAnimationId ).ToString() +
            " (" +
            ( !this.isInTransition ? "no transition" : this.transitionProgress.ToString() + " processed" ) +
            ").",
        new Vector2( 50, 85 ),
        Color.White );
 
    // Draw the current position and length of the animation being rendered.
    if ( currentAnimation != null )
    {
        this.spriteBatch.DrawString(
            this.font,
            "Processed " +
            this.currentAnimation.CurrentPosition.ToString() +
                " of " +
                this.currentAnimation.Length.ToString() +
                ".",
            new Vector2( 50, 115 ),
            Color.White );
    }
 
    // Flush the batch.
    this.spriteBatch.End();
 
    // As usual, call the base method.
    base.Draw( gameTime );
}

El único código de importancia aquí es el que está a cargo de dibujar o bien la animación tal cual es, o bien la transición calculada.

Puesto que el método Draw de AvatarAnimation espera una instancia del tipo “IList”, podemos pasar un arreglo de matrices directamente sin crear una colección “ReadOnlyCollection”.

7. Finalmente, la magia:

/// <summary>
/// Calculates the proper bone transoforms for the current transition.
/// </summary>
private unsafe void CalculateTransition()
{
    // If so, declare all needed helper primitives.
    // For debugging purposes you can use local fields marked as "final",
    // which in this example are commented out.
    Matrix currentMatrix, targetMatrix;
    Vector3 currentScale, targetScale; //, finalScale;
    Quaternion currentRotation, targetRotation; //, finalRotation;
    Vector3 currentTranslation, targetTranslation; //, finalTranslation;
 
    // For each transform's matrix.
    for ( int i = 0; i < this.transitionTransforms.Length; i++ )
    {
        // Since we are pointing to a managed struct we must use the 
        // reserved word "fixed" with an "unsafe" method declaration,
        // if we want to avoid traversing the array several times.
        fixed ( Matrix* matrixPointer = &this.transitionTransforms[ i ] )
        {
            // Get both, the current and target matrices.
            // Declaring these two local fields could be omitted 
            // and be used directly in the calcs below, but 
            // they are really useful when debugging.
            currentMatrix = *matrixPointer;
            targetMatrix = this.currentAnimation.BoneTransforms[ i ];
 
            // Get the components for the current matrix.
            currentMatrix.Decompose(
                out currentScale,
                out currentRotation,
                out currentTranslation );
 
            // Get the components for the target matrix.
            targetMatrix.Decompose(
                out targetScale,
                out targetRotation,
                out targetTranslation );
 
            // There's no need to calculate the blended scale factor, since we
            // are mantaining the current one, but I include it in the example
            // for learning purposes in case you need it.
            /*Vector3.Lerp(
                ref currentScale,
                ref targetScale,
                this.transitionProgress,
                out currentScale );*/
 
            // Interpolate a rotation value from the current an target ones,
            // taking into account the progress of the transition.
            Quaternion.Slerp(
                ref currentRotation,
                ref targetRotation,
                this.transitionProgress,
                out currentRotation );
 
            // Interpolate a translation value from the current an target ones,
            // taking into account the progress of the transition.
            Vector3.Lerp(
                ref currentTranslation,
                ref targetTranslation,
                this.transitionProgress,
                out currentTranslation );
 
            // Calculate the corresponding matrix with the final components.
            // Again, in this example, the creation of the scale matrix can be omitted
            // from the formula below (you may only use the rotation and tranlsation
            // factors obtaining the same result and save some processing power per loop).
            //this.transitionTransforms[ i ] =
            *matrixPointer =
                //Matrix.CreateScale( currentScale ) *
                Matrix.CreateFromQuaternion( currentRotation ) *
                Matrix.CreateTranslation( currentTranslation );
        }
    }
}

Cada vez que necesitemos calcular y actualizar las matrices de transformación interpoladas, vamos a tener que descomponer las matrices de la última posición de la animación que finaliza y la de la posición de la animación entrante.

Si la última posición de la animación más antigua está “fija”, por qué necesitamos descomponer sus matrices cada vez que llamamos a este método? Muy buena pregunta. Respuesta corta: no lo necesitamos. Podríamos calcular las matrices de transformación viejas una vez y almacenarlas en un campo privado para el juego, pero las incluyo en caso que desen cambiar el comportamiento por defecto (por ejemplo, si deciden mantener en ejecución la animación que está por finalizar así como también la de la que ingresa).

Qué pasa con las palabras “unsafe” y “fixed”? Debemos declarar ambas a los efectos de utilizar punteros a tipos por valor (y además marcar el proyecto para que permita código inseguro). Fijar el campo va a indicar al GC de no recolectarlo hasta que no lo precisemos más, y, en este caso, nos permitirá usar directamente el valor guardado en la pila de memoria sin tener que recorrer el arreglo de las matrices de transformación de las transiciones dos veces por bucle “for”, primero para obtener el valor y luego para actualizarlo.

Fiúuu! Esto es todo por ahora; cuando compilen y corran el programa verán como un avatar creado al azar realizan unas lindas transiciones o bien cuando una animación finaliza o cuando Uds. pulsan el botón ‘A’.

Pueden descargar el proyecto de ejemplo de este artículo desde aquí (van a encontrar una clase extra con dos métodos de extensión para la clase AvatarAnimation, llamados “LastFrameReached” y “RemainingTime”).

Pueden modificar el comportamiento y optimizar el código un poco (digamos la forma de dibujar, crear una clase específica para los avatares y demás), o esperar las partes de la serie que se vienen en breve …

Que lo disfruten!
~Pete

> Vínculo a la versión en inglés.