| < 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:
Avrete notato che la semantica TEXCOORD non viene gestita nel metodo factory. Questo accade per due motivi:
- Dato che ora disegneremo un terreno di un singolo colore, senza quindi usare texture, le coordinate texture sono inutili
- Anche nel caso dovessimo disegnare un terreno texturizzato, non ci sarebbe comunque bisogno di specificare le coordinate texture. Dato che la texture deve essere stesa su tutta la griglia, sono le stesse coordinate x e z di ciascun vertice che definiscono, a meno di un fattore di scala, le coordinate texture necessarie
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:
- il colore: essendo uniforme per tutto il terreno, esso diventa un parametro globale dello shader
- la luce: il calcolo delle luci e delle ombre è molto semplice e si serve, oltre che delle normali, di due parametri globali, LightDirection e AmbientFactor
Il calcolo della luce si risolve quindi a determinare un numero compreso tra 0 e 1 che indica quanta luce riceve una data primitiva:
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:
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.
