domingo, 5 de julio de 2009

“XNAVATARES” - PARTE 2 – ¿SIN SOMBRAS?

Como prometí, aquí presento la segunda parte de esta serie de artículos sobre el uso de Avatares con XNA GS para la XBox 360.

Si recuerdan, en mi primer artículo mostré como dibujar en pantalla un avatar animado teniendo en cuenta transiciones entre dos animaciones.

En esta parte, hablaré de un factor que ayudará a mejorar un poco el “dulce visual” en sus juegos cuando utilicen avatares: sombras.

Como deben saber, el sistema de renderizado de avatares no nos permite usar efectos de shaders personalizados para dibujar avatares en panatalla; en cambio, debemos únicamente usar el sistema incluido por defecto para lograr esta tarea.

Por un lado, simplifica las cosas pero por otro, limita un poco nuestras posibilidades.

La emisión de sombras es un ejemplo de dicha limitante. Como voy a mostrar en un minuto o dos, una solución alternativa “barata” puede ser utilizada, para juegos simples.

La técnica que usaré se conoce como “Sombras Planas” (el framework de XNA incluye todo lo que precisamos para esto). Es un sustituo muy básico de sombras “reales”, pero va a lograr el truco con lo justo para proyectos que no requieran effectos de sombreado “puntillozos”.

Utilizaremos el proyecto que había incluído la vez anterior como punto de partida y enfocándonos principalmente en el código que se agregará y/o modificará.

1. Campos a agregar:

private Matrix[] transitionTransforms, shadowTransforms;
...
private Plane plane;
private float lightRotation;
private Model floor;

Qué hay de nuevo? El arreglo de ‘transformación de sombras’ guardará las matrices que vamos a necesitar para achatar el modelo en base a un plano de referencia, el cual también definimos.

2. El constructor:

...
// Create the array of matrices that will hold bone transforms for shadows.
this.shadowTransforms = new Matrix[ 71 ];
...

No hay nada extravagante aquí. Sólo creamos el arreglo que almacenará la colección de matrices con las que se achatará el modelo.

3. Inicializando el juego:

/// <summary>
/// Allows the game to perform any initialization it needs to before starting to run.
/// This is where it can query for any required services and load any non-graphic
/// related content.  Calling base.Initialize will enumerate through any components
/// and initialize them as well.
/// </summary>
protected override void Initialize()
{
    this.plane = new Plane( Vector3.Up, 0 );
 
    // As usual, initialize all compoenents.
    base.Initialize();
}

Unicamente creamos el plano de referencia con una normal mirando hacia arriba y sin movernos sobre dicha normal (por lo que es un plano XZ donde la coordenada Y es cero, inicialmente).

4. Cargando contenido:

...
// Set the "World" value wit a rotarion of 180º.
this.avatarRenderer.World = Matrix.CreateTranslation(
    Vector3.Right * -1 + Vector3.Up * 0 + Vector3.Forward * -1 ) *
    Matrix.CreateRotationY( MathHelper.Pi );
...

Sólo modificamos la línea que ubica al avatar en el mundo.

5. Actualizando el mundo:

Sólo agregaremos esta línea:

...
// Update the value used to rotate the light.
this.lightRotation += .5f * (float)gameTime.ElapsedGameTime.TotalSeconds;
...

Así que la luz rotará para mostrar el efecto.

6. Dibujando el 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 );
 
    // Create the array of bone transforms for the floor and populate it.
    ModelBone[] transforms = new ModelBone[ this.floor.Bones.Count ];
    this.floor.Bones.CopyTo( transforms, 0 );
 
    // For each mesh in the floor model.
    foreach(var mesh in this.floor.Meshes)
    {
        // Get the basic effect.
        foreach ( BasicEffect effect in mesh.Effects )
        {
            // Set values and commit changes.
            effect.DiffuseColor = Color.LightSteelBlue.ToVector3();
            effect.View = this.avatarRenderer.View;
            effect.Projection = this.avatarRenderer.Projection;
            effect.World = transforms[ mesh.ParentBone.Index ].Transform;
            effect.CommitChanges();
        }
 
        // Finally, draw the mesh.
        mesh.Draw();
    }
 
    // 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.
            this.avatarRenderer.Draw(
                this.transitionTransforms,
                currentAnimation.Expression );
        }
        else
        {
            // If not, draw it with the actual transforms.
            this.avatarRenderer.Draw(
                this.currentAnimation.BoneTransforms,
                currentAnimation.Expression );
        }
 
        // Make the light sources of the avatar dark.
        Vector3 ambientColor = this.avatarRenderer.AmbientLightColor;
        Vector3 lightColor = this.avatarRenderer.LightColor;
        this.avatarRenderer.AmbientLightColor =
            this.avatarRenderer.LightColor =
                -10 * Vector3.One;
 
        // Enable alpha blending.
        GraphicsDevice.RenderState.AlphaBlendEnable = true;
        GraphicsDevice.RenderState.SourceBlend = Blend.SourceAlpha;
        GraphicsDevice.RenderState.DestinationBlend = Blend.InverseSourceAlpha;
 
        // Change the depth bias just a bit to avoid z-fighting.
        float sourceDepthBias = GraphicsDevice.RenderState.DepthBias;
        GraphicsDevice.RenderState.DepthBias = -0.0001f;
 
        // Set the new light direction.
        this.avatarRenderer.LightDirection = Vector3.Normalize(
            Vector3.Right * 7.5f * (float)Math.Cos( lightRotation ) +
            Vector3.Forward * 15.0f * (float)Math.Sin( lightRotation ) +
            Vector3.Up * 10.0f );
 
        // If the avatar is stepping over the floor, then move the plane 
        // according to the "altitude" of the avatar in the world so as
        // to calculate and cast shadows in the correct world position
        // (also, take into account that in case of a "jump" movement, in a 
        // "complete" shadow system you must reposition the shadow along the 
        // floor taking into account the place where the light-ray hits the 
        // floor while it points to the avatar; otherwise, it will stand still 
        // as if the avatar never jumped in the first place).
        this.plane.D = -this.avatarRenderer.World.Translation.Y;
 
        // Calculate and set the world transform that will flatten the 
        // avatar's geometry, taking into account the original rotation,
        // scale and translation factors.
        Matrix world = this.avatarRenderer.World;
        this.avatarRenderer.World *= Matrix.CreateShadow(
               this.avatarRenderer.LightDirection,
               this.plane );
 
        // Is the animation in transition?
        if ( this.isInTransition )
        {
            // If so, draw it with the interpolated transforms.
            this.avatarRenderer.Draw(
                this.transitionTransforms,
                currentAnimation.Expression );
        }
        else
        {
            // If not, draw it with the actual transforms.
            this.avatarRenderer.Draw(
                this.currentAnimation.BoneTransforms,
                currentAnimation.Expression );
        }
 
        // Reset all affected values.
        this.avatarRenderer.World = world;
        this.avatarRenderer.AmbientLightColor = ambientColor;
        this.avatarRenderer.LightColor = lightColor;
        GraphicsDevice.RenderState.DepthBias = sourceDepthBias;
        GraphicsDevice.RenderState.AlphaBlendEnable = false;
    }
 
    // 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 );
}

Aquí es donde ocurren la mayoría de los cambios; el juego ...:

  1. ... dibuja el avatar tal cual lo hiciera en mi ejemplo previo,
  2. ... cambia los valores de iluminación que afectan al avatar.
  3. ... modifica el ajuste de profundidad a efectos de evitar cualquier disputa de profundidad eventual (z-fighting),
  4. ... rota la luz y ajusta la altitud del plano de referencia antes de achatar el modelo,
  5. ... actualiza la posición del modelo aplanado usando el método estático del estructurado del tipo matríz, al cual llamamos “CreateShadow”,
  6. ... dibuja la sombra falsa, y finalmente ...
  7. ... restaura los valores de las luces y también de la posición de las mayas del modelo del avatar.

7: Cambios al código de transición: ninguno.

Si todo sale bien verán algo como esto:

Cosas a tener en cuenta, no obstante:

  1. Las sombras se dibujarán aun cuando no hayan elementos donde plasmar sombras (vean como parte de la sombra  se dibuja más allá del “suelo” por un breve lapso de tiempo),
  2. Tendrán que modificar la ubicación de las sombras cuando se cambia la altura en la posición del avatar (i.e.: si salta),
  3. Las mallas del modelo se achatan en un plano de referencia, por lo que únicamente funcionará para objetos situados en dicho plano específico (como, en mi ejemplo, un piso), y
  4. Por ende, no hay auto-sombreado.

Un enfoque más preciso sería extender el ejemplo tratando de utilizar la “memoria cliché” (stencil buffer) y datos de profundidad, como se explica al final de esta discusión.

Bien, eso es todo por hoy. Aquí pueden encontrar el código fuente de este ejemplo.

Salúd!
~Pete

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