flopoloco's picture

Tank shooter example!

I have created a sample application to test various stuff, in short words.

1. Hierarchical / Parental transformations
How to have transformations based on scenegraph parent-child relationship?

2. How vector transformations work?
Exploring extensively how Vector3.Transform method works and when it's useful.

3. How matrix works.
Why exactly do we need matrices?

4. How to have an articulated model?
Given the scenegraph model mentioned earlier, we will try to transform each part independently. However each part will be transformed based on it's parent transformation.

5. How to setup a simple articulated model.
We will setup something simple like a tank that consists of 3 parts: body, turret and cannon.

Some other stuff to mention:

a) Full source code project download here (contains a compiled binary and OpenTK.dll):
https://drive.google.com/folderview?id=0B0YtvOk65FM1cnJTVmo5NlJoRWc&usp=...

b) This is an experimental and educational version, it's not exactly perfect but it works as desired.

c) If I understand more on this subject in the future I will post a much improved version. My goal is to have an all-purpose and top quality Transformation class that will be much more easy and productive to work with.

d) I am very interested to hear any feedback you got so I can learn from mistakes, also I will try to answer any questions you have the best I can.

using System;
using OpenTK;
using OpenTK.Graphics;
using OpenTK.Graphics.OpenGL;
using OpenTK.Input;
using Debug = System.Diagnostics.Debug;
 
namespace OpenTKTransformation
{
	/// <summary>
	/// Simple drawing operations in immediate mode.
	/// </summary>
	static class GLDraw
	{
		/// <summary>The matrix of the camera.</summary>
		public static Matrix4 MatrixCamera;
		/// <summary>The matrix of the model.</summary>
		public static Matrix4 MatrixModel;
 
		static GLDraw()
		{
			MatrixCamera = Matrix4.Identity;
			MatrixModel = Matrix4.Identity;
		}
 
		/// <summary>This method is called right before each drawing operation.</summary>
		static void UpdateMatrix()
		{
			// The result is saved right on the "MatrixModel" for optimization.
			// If we were to allocate a fresh new variable it would be:
			// var finalMatrix = MatrixModel * MatrixCamera;
			// GL.LoadMatrix(ref finalMatrix);
			MatrixModel *= MatrixCamera;
			GL.LoadMatrix(ref MatrixModel);
		}
 
		/// <summary>
		/// Draw an XYZ axis.
		/// </summary>
		public static void Axis()
		{
			UpdateMatrix();
 
			GL.Begin(PrimitiveType.Lines);
 
			// X
			GL.Color3(1f, 0f, 0f);
			GL.Vertex3(0f, 0f, 0f);
			GL.Vertex3(1f, 0f, 0f);
 
			// Y
			GL.Color3(0f, 1f, 0f);
			GL.Vertex3(0f, 0f, 0f);
			GL.Vertex3(0f, 1f, 0f);
 
			// Z
			GL.Color3(0f, 0f, 1f);
			GL.Vertex3(0f, 0f, 0f);
			GL.Vertex3(0f, 0f, 1f);
 
			GL.End();
		}
 
		/// <summary>
		/// Draw a wire cube.
		/// </summary>
		public static void WireCube(float size = 0.5f)
		{
			UpdateMatrix();
 
			// Bottom
			GL.Begin(PrimitiveType.LineLoop);
			GL.Vertex3(-size, -size, -size);
			GL.Vertex3( size, -size, -size);
			GL.Vertex3( size, -size, size);
			GL.Vertex3(-size, -size, size);
			GL.End();
 
			// Top
			GL.Begin(PrimitiveType.LineLoop);
			GL.Vertex3(-size, size, -size);
			GL.Vertex3( size, size, -size);
			GL.Vertex3( size, size,  size);
			GL.Vertex3(-size, size,  size);
			GL.End();
 
			// Vertical
			GL.Begin(PrimitiveType.Lines);
			GL.Vertex3(-size, -size, -size);
			GL.Vertex3(-size,  size, -size);
			GL.Vertex3( size, -size, -size);
			GL.Vertex3( size,  size, -size);
			GL.Vertex3( size, -size, size);
			GL.Vertex3( size,  size, size);
			GL.Vertex3(-size, -size, size);
			GL.Vertex3(-size,  size, size);
			GL.End();
		}
	}
 
	/// <summary>
	/// A class to represent a transformation component.
	/// </summary>
	class Transform
	{
		/// <summary>This represents the local position.</summary>
		public Vector3 Position;
		/// <summary>This represents the local orientation.</summary>
		public Quaternion Orientation;
 
		/// <summary>This is the final global position that will be calculated at the update method.</summary>
		public Vector3 GlobalPosition;
		/// <summary>This is the final global position that will be calculated at the update method.</summary>
		public Quaternion GlobalOrientation;
 
		/// <summary>The parent transform component. It will affect the global position and orientation.</summary>
		public Transform Parent;
		/// <summary>The final calculated transformation matrix to be used in shader uniforms or on immediate opengl.</summary>
		public Matrix4 Matrix;
 
		/// <summary>Local axis extracted from Matrix.</summary>
		public Vector3 Right;
		/// <summary>Local axis extracted from Matrix.</summary>
		public Vector3 Up;
		/// <summary>Local axis extracted from Matrix.</summary>
		public Vector3 Forward;
 
		public Transform()
		{
			Matrix = Matrix4.Identity;
			Orientation = Quaternion.Identity;
		}
 
		public void Update()
		{
			// Calculate the transformation matrix
			Matrix = Matrix4.CreateFromQuaternion(Orientation) * Matrix4.CreateTranslation(Position);
			// Multiply by the parent matrix:
			// Matrix = ParentMatrix * orientation * position
			if (Parent != null) Matrix *= Parent.Matrix;
			// Notice that local "orientation" and "position" used instead of global.
			// It makes more sense to use the global values instead but the result
			// will be an unwanted matrix that slides like crazy.
 
			// Now it's time to calculate the global position and orientation
			if (Parent == null)
				GlobalOrientation = Orientation;
			else
				GlobalOrientation = Parent.Orientation * Orientation;
 
			// It would make sense to calculate the GlobalPosition in the
			// same way as we did with the GlobalOrientation.
			// GlobalPosition = Position;
			// GlobalPosition = Parent.GlobalPosition + Position;
			// This somehow works and puts stuff in the right places
			// but when it comes to Orientations the positions are totally
			// out of place.
 
			// The most obvious way is to use Vector3.Transform method instead,
			// to take Orientation based position seriously into consideration.
			if (Parent == null)
				GlobalPosition = Position;
			else
			{
				// Take this local position and transform based on the parent's orientation.
				// remember that the global orientation is defined by the hierrachy
				var p = Vector3.Transform(Position, Parent.GlobalOrientation);
				GlobalPosition = p + Parent.GlobalPosition;
			}
 
			// As a side node: Since the Matrix of this transform component
			// is already calculated previously it would be handy to extract
			// the transformation values since it's already calculated.
			// But either way I will leave both options here if in any case
			// you prefer one way in favor of another.
 
			// Intead of doing all of the previous if-then-else check
			// for the GlobalPosition variable, simply instead replace with this line:
			// GlobalPosition = Matrix.Row3.Xyz;
 
			// As a meaningful demonstration I will leave this code here
			// as well but if you are very experienced with matrix composition
			// you won't bother keeping these variables as a reference.
 
			// However if you want to provide a friendly API in your application
			// it actually could make sense to provide these stuff as well.
			Right 			= Matrix.Row0.Xyz;
			Up 				= Matrix.Row1.Xyz;
			Forward 		= Matrix.Row2.Xyz;
		}
	}
 
	/// <summary>
	/// A simple dummy class to represent a bullet object
	/// </summary>
	class Bullet
	{
		/// <summary>
		/// This orientation must be set same as the cannon's
		/// </summary>
		public Quaternion Orientation;
		/// <summary>
		/// This is the initial position of the bullet.
		/// </summary>
		public Vector3 Position;
		/// <summary>
		/// This position is supposed to be a little bit ahead of
		/// the initial position just only for drawing operation.
		/// </summary>
		public Vector3 Position2;
		/// <summary>
		/// The actual time when the bullet was spawned, this helps
		/// to delete the bullet after a significant ammount of time.
		/// </summary>
		public float SpawnTime;
 
		public void Update(float time)
		{
			// The speed of the bullet
			var speed = 50f * time;
 
			// This affects how long will be the size of the line
			// for representing the bullet travelling with tremendous speed.
			var size = 1f;
 
			// The travelling of the bullet is very simple
			// just go forward based on the initial given orientation.
			Position += Vector3.Transform(Vector3.UnitZ * speed, Orientation);
 
			// Setting the next position is simple as well
			// just have a new position a little bit in front of the initial position.
 
			// transform a vector about 2 units forward, based on the initial orientation
			// then add this vector to the actual initial position of the bullet.
			Position2 = Position + Vector3.Transform(Vector3.UnitZ * size, Orientation);
 
			// Increase the alive time of the bullet
			SpawnTime += time;
		}
	}
 
	class TankControl
	{
		public float
			MoveSpeed,
			TurnSpeed,
 
			MoveSpeedInitial,
			TurnSpeedInitial,
 
			TurretRotationX,
			TurretRotationY,
 
			CannonSpeed,
			CannonSpeedAcceleration,
			CannonSpeedMax,
			CannonCooldown,
			CannonSpeedActivation,
 
			BulletSpawnTime,
			BulletSpawnInterval,
			BulletLifetime;
 
		public bool CannonTrigger;
	}
 
	class MainClass
	{
		public static void Main(string[] args)
		{
			// A random value generator
			var random = new Random();
 
			// The window
			var window = new GameWindow(800, 600);
 
			// Some matrices to setup the camera view
			var matrixModelview		= Matrix4.Identity;
			var matrixProjection	= Matrix4.Identity;
			var matrixMVP			= Matrix4.Identity;
 
			// The actual parts of the tank model
			var partBody = new Transform();
			var partTurret = new Transform();
			var partCannon = new Transform();
 
			// A list with all the spawned bullets
			var bulletList = new System.Collections.Generic.List<Bullet>();
 
 
			// This function/action will help us to setup the tank model
			Action setupTankStructure = () => 
			{
				partBody.Position = Vector3.Zero;
				partTurret.Position = new Vector3(0, 1, 0); // Turret is 1 unit above
				partCannon.Position = new Vector3(0, 0, 1); // Cannon is 1 unit front of turret
				partTurret.Parent = partBody;
				partCannon.Parent = partTurret;
			};
 
			// A very quick and handy way to pack a bunch of variables related to controlling the tank.
			// You might consider that I could setup a new class for this, but it's too much of an effort
			// for this simple demostration. :)
			var control = new TankControl();
			control.MoveSpeedInitial = 5;
			control.TurnSpeedInitial = 2;
			control.CannonSpeedAcceleration = 2;
			control.CannonSpeedMax = 1;
			control.CannonCooldown = 0.98f;
			control.CannonSpeedActivation = 0.3f;
			control.BulletSpawnInterval = 0.05f;
			control.BulletLifetime = 0.8f;
 
			window.Load += (object sender, EventArgs e) => 
			{
				GL.Enable(EnableCap.DepthTest);
				setupTankStructure();
			};
 
			// Change the control values based on input
			Func<Key, Key, float, float> checkKeyState = (_keyA, _keyB, _value) =>
			{
				if (window.Keyboard[_keyA]) return  _value;
				if (window.Keyboard[_keyB]) return -_value;
				return 0f;
			};
 
			window.UpdateFrame += (object sender, FrameEventArgs e) =>
			{
				var time = (float)e.Time;
				var move = control.MoveSpeedInitial * time;
				var turn = control.TurnSpeedInitial * time;
 
				// Movement and rotation
				control.MoveSpeed			= checkKeyState(Key.W, Key.S,  move);
				control.TurnSpeed			= checkKeyState(Key.A, Key.D, -turn);
				control.TurretRotationX		= checkKeyState(Key.Up, Key.Down, turn);
				control.TurretRotationY		= checkKeyState(Key.Left, Key.Right, -turn);
 
				// Cannon
				if (window.Keyboard[Key.Space])
				{
					control.CannonSpeed += control.CannonSpeedAcceleration * time;
					if (control.CannonSpeed > control.CannonSpeedMax) control.CannonSpeed = control.CannonSpeedMax;
					control.CannonTrigger = true;
				}
				else
				{
					control.CannonTrigger = false;
					control.CannonSpeed *= control.CannonCooldown;
				}
 
				// Update partBody transformation
				// Set the orientation of the body, create a new quaternion rotated by a radian value around the Y axis.
				// Alternatively when we need to use euler angle instead (0..360) we could use this statement:
				// Quaternion.FromAxisAngle(Vector3.UnitY, MathHelper.DegreesToRadians(control.TurnSpeed));
				partBody.Orientation *= Quaternion.FromAxisAngle(Vector3.UnitY, control.TurnSpeed);
				// The position can be calculated with a vector transformation. The resulting vector from
				// this transformation, is a new position that can be added back to the current position.
				partBody.Position += Vector3.Transform(Vector3.UnitZ * control.MoveSpeed, partBody.Orientation);
				// Note: We alter only local orientation and position because the global ones are calculated
				// based on parental relationships as set in the hierarchy.
				partBody.Update();
 
 
				// Update partTurret transformation
				// We calculate two quaternions for both X and Y axices of the turret.
				var qx = Quaternion.FromAxisAngle(Vector3.UnitX, control.TurretRotationX);
				var qy = Quaternion.FromAxisAngle(Vector3.UnitY, control.TurretRotationY);
				// We want to achieve a special kind of orientation effect
				// X local rotation | Y global rotation
				// First we multiply yOrientation with turretOrientation, then we mutliply with xOrientation
				// The reason to do this that it has to do with the order of operations.
				// Changing the order of operations gives completely different effect.
				// This gives 100% local transformations:
//				partTurret.Orientation = partTurret.Orientation * qy * qx;
				// However this breaks the previous behaviour and gives the desired effect.
				partTurret.Orientation = qy * partTurret.Orientation * qx;
				// Update turret as well
				partTurret.Update();
 
 
				// Update partCannon transformation
				partCannon.Orientation *= Quaternion.FromAxisAngle(Vector3.UnitZ, control.CannonSpeed);
				partCannon.Update();
 
 
				// Spawn the bullets
				if (control.CannonSpeed > control.CannonSpeedActivation && control.CannonTrigger)
				{
					// Increase the bullet spawn time
					control.BulletSpawnTime += time;
 
					// If the interval is reached a bullet can be spawned.
					if (control.BulletSpawnTime > control.BulletSpawnInterval)
					{
						var b = new Bullet();
 
						// This is a special orientation to offer a cool shaking effect to the bullets.
						// This shake is defined by X axis which is the pitch of the bullet and the Y
						// axis which is the Yaw of the bullet. These two axices are enough to give
						// a cool effect.
						var shake =
							Quaternion.FromAxisAngle(Vector3.UnitX, (float)random.NextDouble() * 0.1f) *
							Quaternion.FromAxisAngle(Vector3.UnitY, (float)random.NextDouble() * 0.1f);
 
						// The orientation of the bullet is defined by the current global orientation of the turret.
						// We could use partCannon orientation as well but this will make the bullets rotate around
						// the z axis, to avoid any unnecessary confusion we will avoid this option and rather use
						// the orientation of the turret instead which is pure.
						b.Orientation = partTurret.GlobalOrientation * shake;
 
						// The position of the bullet can be same as the global position of the cannon.
						b.Position = partCannon.GlobalPosition;
 
						// The time of bullet spawning
						b.SpawnTime = time;
 
						// Now the bullet can be added to the list with the rest.
						bulletList.Add(b);
 
						// The spawning interval can be reset so the next spawning takes place after a while.
						control.BulletSpawnTime = 0f;
					}
				}
 
				// Update the bullets
				foreach (var i in bulletList) i.Update(time);
				// Delete the bullets
				for (var i = 0; i < bulletList.Count; i++)
					if (bulletList[i].SpawnTime > control.BulletLifetime)
						bulletList.RemoveAt(i);
			};
 
			window.Resize += (object sender, EventArgs e) => 
			{
				var aspect = 4f / 3f;
				var viewWidth = (float)window.Width;
				var viewHeight = (float)window.Width / aspect;
				if (viewHeight > window.Height)
				{
					viewWidth = window.Height * aspect;
					viewHeight = window.Height;
				}
				var viewX = (window.Width - viewWidth) / 2f;
				var viewY = (window.Height - viewHeight) / 2f;
				GL.Viewport((int)viewX, (int)viewY, (int)viewWidth, (int)viewHeight);
				matrixProjection = Matrix4.CreatePerspectiveFieldOfView(
					MathHelper.PiOver4, (float)window.Width/window.Height, 0.1f, 1000f);
			};
 
			// This is a very simple action/function just for testing the 
			// local axices of component as extracted from the matrix.
			// I added this on the beginning of the application for debugging
			// reasons and I could remove it now since it served it's purpose
			// but I rather leave it here if in case there's something interesting
			// for you to see.
			Action<Transform> debugDrawLocalAxis = (_transform) =>
			{
				GL.LineWidth(2f);
 
				GL.Begin(PrimitiveType.Lines);
 
				GL.Color3(1f, 0f, 0f);
				GL.Vertex3(_transform.GlobalPosition);
				GL.Vertex3(_transform.GlobalPosition + _transform.Right);
 
				GL.Color3(0f, 1f, 0f);
				GL.Vertex3(_transform.GlobalPosition);
				GL.Vertex3(_transform.GlobalPosition + _transform.Up);
 
				GL.Color3(0f, 0f, 1f);
				GL.Vertex3(_transform.GlobalPosition);
				GL.Vertex3(_transform.GlobalPosition + _transform.Forward);
				GL.End();
 
				GL.LineWidth(1f);
			};
 
			window.RenderFrame += (object sender, FrameEventArgs e) =>
			{
				GL.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
 
				// Update the camera
				matrixModelview = Matrix4.LookAt(new Vector3(5f, 20f, -10f), Vector3.Zero, Vector3.UnitY);
				// Reverse the X axis is just for convinience since we want to consider left -X and right +X
				matrixModelview *= Matrix4.CreateScale(-1f, 1f, 1f);
				matrixMVP = matrixModelview * matrixProjection;
 
				GLDraw.MatrixCamera = matrixMVP;
 
				GL.Color3(.8f, .8f, .8f);
 
				// Draw the body
				GLDraw.MatrixModel = partBody.Matrix;
				GLDraw.WireCube();
				// Draw the turret
				GLDraw.MatrixModel = partTurret.Matrix;
				GLDraw.WireCube();
				// Draw the cannon
				GL.Color3(.5f, .5f, .5f);
				GLDraw.MatrixModel = partCannon.Matrix;
				GLDraw.WireCube();
 
				// Debug draw the axis of each part
				// Alternatively I could use GLDraw.Axis();
				// but I wanted specifically to see how a matrix is composed.
				GL.LoadMatrix(ref matrixMVP);
				debugDrawLocalAxis(partBody);
				debugDrawLocalAxis(partTurret);
				debugDrawLocalAxis(partCannon);
 
				// Draw the bullets
				GL.Color3(1f, 1f, 0f);
				GL.PointSize(2f);
				GL.Begin(PrimitiveType.Lines);
				foreach (var i in bulletList)
				{
					GL.Vertex3(i.Position);
					GL.Vertex3(i.Position2);
				}
				GL.End();
				GL.PointSize(1f);
 
				window.SwapBuffers();
			};
 
			window.Run();
		}
	}
}