Cerca Totem's Lair su Facebook



< Precedente
Creare la geometria del terreno


Creare la geometria
Ora che disponiamo di una height map completa, dobbiamo solo convertire i dati sulle altezze in una geometria reale, ossia dobbiamo creare i vertici e gli indici che servono per renderizzare il terreno. Ho già spiegato nel tutorial precedente in che modo si struttura la griglia del terreno, perciò si tratta solo di implementare la logica che ci sta dietro:
namespace XNA4_Tutorial3
{
    // ...
    
    // IGeometry è l'interfaccia definita nella seconda sezione. Rappresenta una 
    // qualsiasi entità geometrica calcolata a runtime
    public class TerrainGeometry<T> : IGeometry<T>
        where T : struct, IVertexType
    {
        public T[] Vertices { get; private set; }
        public Int32[] Indices { get; private set; }

        public Int32 Width { get; private set; }
        public Int32 Length { get; private set; }

        private TerrainGeometry() { }

        public static TerrainGeometry<T> FromHeightMap(HeightMap heightMap)
        {
            T vertexSample = new T();

            // HasSemantics, GetElement e WriteToMemory sono i metodi di estensioni scritti
            // nell'ultimo tutorial della seconda sezione
            
            // Imponiamo che esista sia una semantica per la posizione che una per la normale.
            // Il terreno dovrà infatti essere opportunamente illuminato e non è possibile
            // calcolare la luce senza disporre delle normali
            if (!vertexSample.HasSemantics(VertexElementUsage.Position, VertexElementUsage.Normal))
                throw new InvalidOperationException("The specified vertex type " + typeof(T).Name + " does not expose any correct semantics for position and normal elements.");

            VertexElement vertexPosition = vertexSample.GetElement(VertexElementUsage.Position);
            VertexElement vertexNormal = vertexSample.GetElement(VertexElementUsage.Normal);

            T[] vertices = new T[heightMap.Width * heightMap.Length];
            Vector3[] positions = new Vector3[vertices.Length];
            Vector3[] normals = new Vector3[vertices.Length];
            // Crea un array di indici adattai per una griglia di data larghezza e lunghezza
            Int32[] indices = GenerateGridIndices(heightMap.Width, heightMap.Length);

            // Calcola le posizioni dei vertici
            for (Int32 x = 0; x < heightMap.Width; x++)
                for (Int32 z = 0; z < heightMap.Length; z++)
                    positions[x + z * heightMap.Width] = new Vector3(x, heightMap.GetHeight(x, z), z);

            // Calcola le normali
            for (Int32 i = 0; i < indices.Length / 3; i++)
            {
                // Prende degli indici a tre a tre: essi individuano quindi un triangolo
                Int32 index1 = indices[3 * i];
                Int32 index2 = indices[3 * i + 1];
                Int32 index3 = indices[3 * i + 2];

                // Calcola due vettori che rappresentano due lati del triangolo
                Vector3 side1 = positions[index2] - positions[index1];
                Vector3 side2 = positions[index1] - positions[index3];
                // Eseguendo un prodotto vettoriale tra questi due vettori se ne ottiene un
                // terzo perpendicolare ad entrambi. Dato che il piano su cui essi giacciono
                // coincide con quello individuato dal triangolo, normal è a tutti gli
                // effetti perpendicolare alla superficie della primitiva.
                // Notate che gli indici sono stati presi in modo da far risultare la normale
                // uscente sulle facce definite in senso orario
                Vector3 normal = Vector3.Cross(side1, side2);

                // Le normali sono caratteristiche delle primitive, ma vengono assegnate ai vertici,
                // perciò la normale di un vertice coincide con la somma, normalizzata,
                // di tutte le normali dei triangoli di cui il vertice fa parte
                normals[index1] += normal;
                normals[index2] += normal;
                normals[index3] += normal;
            }

            // Riduce tutte le normali a versori
            for (Int32 i = 0; i < normals.Length; i++)
                normals[i].Normalize();

            // Setta i campi position e normal di tutti i vertici copiandoli direttamente
            // in memoria tramite puntatori
            for (Int32 x = 0; x < heightMap.Width; x++)
                for (Int32 z = 0; z < heightMap.Length; z++)
                {
                    Int32 i = x + z * heightMap.Width;
                    IntPtr vertexAddress = Marshal.UnsafeAddrOfPinnedArrayElement(vertices, x + z * heightMap.Width);
                    positions[i].WriteToMemory(vertexAddress + vertexPosition.Offset);
                    normals[i].WriteToMemory(vertexAddress + vertexNormal.Offset);
                }

            // Crea e restituisce l'istanza di TerrainGeometry
            TerrainGeometry<T> result = new TerrainGeometry<T>();
            result.Vertices = vertices;
            result.Indices = indices;
            result.Width = heightMap.Width;
            result.Length = heightMap.Length;

            return result;
        }

        // Genera una serie di indici adatti a una griglia di larghezza e lunghezza dati
        private static Int32[] GenerateGridIndices(Int32 width, Int32 length)
        {
            Int32[] indices = new Int32[width * length * 6];

            Int32 counter = 0;
            // I contatori dei due for rappresentano man mano le coordinate di un
            // vertice sulla griglia. Questo vertice è l'estremo superiore sinistro
            // del quadrato che lo contiene
            for (Int32 x = 0; x < width - 1; x++)
                for (Int32 z = 0; z < length - 1; z++)
                {
                    // Calcola gli indici dei vertici del quadrato
                    Int32 topLeft = x + z * width;
                    Int32 topRight = (x + 1) + z * width;
                    Int32 lowerLeft = x + (z + 1) * width;
                    Int32 lowerRight = (x + 1) + (z + 1) * width;

                    // Definisce i due triangoli, in senso orario
                    
                    indices[counter] = topLeft;
                    indices[counter + 1] = lowerRight;
                    indices[counter + 2] = lowerLeft;
                    counter += 3;

                    indices[counter] = topLeft;
                    indices[counter + 1] = topRight;
                    indices[counter + 2] = lowerRight;
                    counter += 3;
                }

            return indices;
        }
    }
}
L'immagine che segue illustra graficamente come viene calcolata la normale:

Calcolo delle normali
Avrete notato che la semantica TEXCOORD non viene gestita nel metodo factory. Questo accade per due motivi:

VertexPositionNormal
Per il primo rendering del terreno con un solo colore uniforme, quindi, abbiamo bisogno solamente di una struttura dati che contenga posizione del vertice e sua normale. Tuttavia questa struttura non esiste di default in XNA, perciò la dovremo creare:
namespace XNA4_Tutorial3
{
    // ...
    
    public struct VertexPositionNormal : IVertexType
    {
        public Vector3 Position;
        public Vector3 Normal;

        // Dimensione, in byte, della struttura dati
        public static Int32 Size = (3 + 3) * 4;
        // VertexElements contiene i VertexElement che definiscono formato, offset e
        // semantica di ciascun campo della struttura
        public static VertexElement[] VertexElements = new VertexElement[] 
        { 
            new VertexElement(0, VertexElementFormat.Vector3, VertexElementUsage.Position, 0), 
            new VertexElement(4 * 3, VertexElementFormat.Vector3, VertexElementUsage.Normal, 0)
        };

        public readonly static VertexDeclaration VertexDeclaration = new VertexDeclaration(VertexElements);

        VertexDeclaration IVertexType.VertexDeclaration
        {
	        get { return VertexDeclaration; }
        }
    }
}


Lo shader
Lo shader per il terreno è abbastanza simile a quello contenuto nel file ColoredPrimitives.fx, affrontato nella seconda sezione. Le uniche cose davvero importanti che cambiano sono: LightDirection indica la direzione dei raggi luminosi, mentre AmbientFactor indica il coefficiente di diffusione luminosa dell'ambiente: se questo valore è 0, i triangoli non colpiti dalla luce saranno completamente neri; se è maggiore di 0, essi saranno tanto più chiari quanto più il valore si avvicina a 1. Quando AmbientFactor è 1, la luce diventa irrilevante, dato che tutti i triangoli sono illuminati al massimo indipendentemente dalla loro inclinazione.
Il calcolo della luce si risolve quindi a determinare un numero compreso tra 0 e 1 che indica quanta luce riceve una data primitiva:

Luce sulla superficie
Se la normale della superficie è completamente opposta al verso della luce, essa riceverà il 100% di illuminazione. Al contrario, se è inclinata, ad esempio, di 45°, ne riceverà soltanto il 70%. La funzione che determina "quanto bene" due versori coincidano è ovviamente il prodotto scalare. Ecco allora il codice dello shader:
float4x4 World;
float4x4 View;
float4x4 Projection;
float4   Color;
float3   LightDirection;
float    AmbientFactor;

struct VertexShaderInput
{
    float4 Position : POSITION0;
    float3 Normal   : NORMAL0;
};

struct VertexShaderOutput
{
    float4 Position : POSITION0;
    // Anche se Light è un coefficiente qualsiasi e non proprio un colore
    // possiamo comunque usare la semantica COLOR, dato che constribuisce in ogni
    // caso al calcolo del colore. Non esiste una semantica dedicata alla luce
    float  Light    : COLOR0;
};

VertexShaderOutput VertexShaderFunction(VertexShaderInput input)
{
    VertexShaderOutput output;

    float4 worldPosition = mul(input.Position, World);
    float4 viewPosition = mul(worldPosition, View);
    output.Position = mul(viewPosition, Projection);

    // saturate(a) restituisce a se 0 <= a < 1, altrimenti restituisce
    // 0 se a < 0 oppure 1 se a > 1. Serve a costringere un valore in un
    // certo intervallino unitario tra 0 e 1.
    // dot(a, b) calcola il prodotto scalare tra a e b. LightDirection ovviamente
    // è invertita perché vogliamo ottenere il massimo quando i
    // vettori sono totalmente discordi. Dato che dot può variare tra -1 e 1,
    // deve essere saturato.
    // Al risultato di dot si aggiunge il fattore di diffusione luminosa dell'ambiente.
    // Alla fine si risatura tutto per avere un valore in [0, 1]
    output.Light = saturate(saturate(dot(input.Normal, -LightDirection)) + AmbientFactor);

    return output;
}

float4 PixelShaderFunction(VertexShaderOutput input) : COLOR0
{
    // Color è una variabile globale impostata da XNA. Dato che Color è
    // un semplice vettore, moltiplicarlo per uno scalare significa moltiplicare tutte
    // le sue componenti per uno scalare
    return Color * input.Light;
}

technique Technique1
{
    pass Pass1
    {
        VertexShader = compile vs_2_0 VertexShaderFunction();
        PixelShader = compile ps_2_0 PixelShaderFunction();
    }
}


Il wrapper dello shader
La classe che rappresenta lo shader espone come proprietà le tre variabili globali dello shader:
namespace XNA4_Tutorial3
{
    // ...
    
    public class ColoredTerrainShader : Shader3D
    {
        public Color Color { get; set; }
        public Vector3 LightDirection { get; set; }
        public Single AmbientFactor { get; set; }

        public ColoredTerrainShader(Game game)
            : base(game, "Shaders\\ColoredTerrain")
        {
        }

        protected override void SetParameters(GameTime gameTime)
        {
            effect.Parameters["Color"].SetValue(this.Color.ToVector4());
            effect.Parameters["LightDirection"].SetValue(this.LightDirection);
            effect.Parameters["AmbientFactor"].SetValue(this.AmbientFactor);
            effect.GraphicsDevice.RasterizerState = RasterizerState.CullCounterClockwise;
        }
    }
}


Il componente finale
La classe ColoredTerrain è il componente finale che useremo:
namespace XNA4_Tutorial3
{
    public class ColoredTerrain : DrawableGameComponent
    {
        private TerrainGeometry<VertexPositionNormal> baseGeometry;
        public ColoredTerrainShader Shader { get; private set; }

        public ColoredTerrain(Game game, TerrainGeometry<VertexPositionNormal> baseGeometry) : base(game)
        {
            this.baseGeometry = baseGeometry;
        }

        public ColoredTerrain(Game game, HeightMap heightMap)
            : this(game, TerrainGeometry<VertexPositionNormal>.FromHeightMap(heightMap))
        {
        }

        public override void Initialize()
        {
            base.Initialize();
            // Inizializza lo shader
            this.Shader = new ColoredTerrainShader(this.Game);
            // Imposta la geometria
            this.Shader.SetTarget(baseGeometry);
            // Assegna valori di default ai campi dello shader
            this.Shader.Color = Color.White;
            this.Shader.LightDirection = Vector3.Normalize(new Vector3(-1, -0.5f, 0));
            this.Shader.AmbientFactor = 0.3f;
        }

        public override void Draw(GameTime gameTime)
        {
            this.Shader.Draw(gameTime);
        }
    }
}
Aggiungiamo il nuovo componente a un nuovo progetto vuoto, insieme ai soliti controlli per tastiera e telecamera. L'immagine usata come height map è la seguente:

Una heightmap
namespace XNA4_Tutorial3
{
    public class Game1 : Microsoft.Xna.Framework.Game
    {
        GraphicsDeviceManager graphics;

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

        private void CreateMovingFpsCamera(Vector3 startPosition, Single velocity)
        {
            FirstPersonCamera fpCamera = new FirstPersonCamera(this);
            fpCamera.Projection = Matrix.CreatePerspectiveFieldOfView(MathHelper.Pi / 3, this.GraphicsDevice.Viewport.AspectRatio, 0.1f, 800);
            fpCamera.Position = startPosition;
            
            FirstPersonCameraController fpController = new FirstPersonCameraController(fpCamera);

            this.Services.AddService(typeof(ICameraService), fpCamera);
            this.Components.Add(fpCamera);
            this.Components.Add(fpController);

            KeyboardController keyboardController = new KeyboardController(this);

            keyboardController.Bind(Keys.W, KeyboardController.KeyPressMode.AlwaysPressed,
                (time) => { fpCamera.Position += fpCamera.Forward * velocity * (Single)time.ElapsedGameTime.TotalSeconds; });
            keyboardController.Bind(Keys.S, KeyboardController.KeyPressMode.AlwaysPressed,
                (time) => { fpCamera.Position -= fpCamera.Forward * velocity * (Single)time.ElapsedGameTime.TotalSeconds; });
            keyboardController.Bind(Keys.D, KeyboardController.KeyPressMode.AlwaysPressed,
                (time) => { fpCamera.Position += fpCamera.Right * velocity * (Single)time.ElapsedGameTime.TotalSeconds; });
            keyboardController.Bind(Keys.A, KeyboardController.KeyPressMode.AlwaysPressed,
                (time) => { fpCamera.Position -= fpCamera.Right * velocity * (Single)time.ElapsedGameTime.TotalSeconds; });

            this.Components.Add(keyboardController);
        }

        protected override void Initialize()
        {
            HeightMap map1 = HeightMap.FromTexture(Content.Load("Textures\\Map1"), 8, 8);
            map1.ScaleHeights(20);

            ColoredTerrain terrain = new ColoredTerrain(this, map1);
            this.Components.Add(terrain);

            CreateMovingFpsCamera(new Vector3(map1.Width / 2, 20, map1.Length / 2), 40);

            base.Initialize();
            this.IsMouseVisible = true;
        }

        protected override void LoadContent()
        {
        }

        protected override void UnloadContent()
        {
            
        }

        protected override void Update(GameTime gameTime)
        {
            base.Update(gameTime);
        }

        protected override void Draw(GameTime gameTime)
        {
            GraphicsDevice.Clear(Color.CornflowerBlue);
            base.Draw(gameTime);
        }
    }
}
Ed ecco il risultato.

< Precedente