Introduction
This piece of work brings together the basic principles of Mathematics and Physics. This app is a mind teaser in three dimensional space, the idea is to push cubes in left/right, front/back, top/down directions in a 3D mesh using a yellow cube aka magnet and make a pattern as shown on the left side in Figure 1. Once a pattern is made, the pattern vanishes and the game concludes.
Background
Here is a video demonstration:
Using the Code
Figure 1
The above picture is a snapshot of the application, written in WPF using the MVVM pattern. The codes make use of a camera, lights (point, direction), custom geometry (modelvisual3d
), delegatecommand
, custom trigger action, dependency property, and many more.
I will drive the article in the form of questions and answers (what the code does along with code piece).
What is perspective-camera, position, lookdirection, fieldofView ?
Perspective camera projects 3D object on 2D surface in more realistic way:
Position
tells us where in 3D space camera is located, default value is Point3D(00, 125, 855)
. - Look-direction is a
3DVector
(magnitude/direction) and tells us where to look from camera position, default value is Vector3D(0, -125, -855)
.
FieldofView
is an angle, defines how enlarge the object in 3D view is projected. The figure below shows different fields of view angles formed by same coloured lines, default value is 70
, its like zoom-in and zoom-out feature, incrementing fieldofview
cause zoom-in, decrementing fieldofview
cause zoom-out.
What Pattern to Make and Controls to Move Magnet?
Build the pattern shown above by moving the magnet and pushing other cubes in any direction. Magnet when active attracts other cubes from all directions in its line towards itself. The second picture shows keyboard keys to move the magnet, to activate magnet press space/enter, use pageup/pagedown to move front and back, alternate keys are (f - left, g - front, v - down, t - up, h - right, y - back, space - magnet).
private Model3DGroup TargetFigure();
How to Build Mesh?
Mesh is network of lines, connected together to form a structure with empty blocks, in these empty blocks magnet moves, these lines are actually a cylinder with radius 1, Mesh.cs is responsible for creating, rotating, translating and positioning lines/cylinder to form a 3D mesh.
Sample code from Mesh.cs creates a cylinder3d
.
Cylinder3D cylinder = new Cylinder3D();
cylinder.Length = Math.Abs((Constants.NoofBlocksInXdirection) * cubeLength + 4);
cylinder.Radius = cylinderRadius;
cylinder.Material = new DiffuseMaterial(colorBrush);
cylinder.BackMaterial = new DiffuseMaterial(colorBrush);
TranslateTransform3D Transalte = new TranslateTransform3D();
Transalte.OffsetX = xCoordinate + 2;
Transalte.OffsetY = yCoordinate + cubeLength * levels;
Transalte.OffsetZ = zCoordinate;
RotateTransform3D ROTATE = new RotateTransform3D();
Vector3D vector3d = new Vector3D(0, 0, 1);
ROTATE.Rotation = new AxisAngleRotation3D(vector3d, 90);
Transform3DGroup myTransformGroup = new Transform3DGroup();
myTransformGroup.Children.Add(ROTATE);
myTransformGroup.Children.Add(Transalte);
cylinder._content.Transform = myTransformGroup;
modelGroup.Children.Add(cylinder._content);
How Lines Are Made?
This is a zoom image of a portion of line which actually is a cylinder, cylinder is MeshGeometry3D
, mesh geometry is collection positions and triangles indices.
Positions is collection of coordinates, divide the line length into parts, create circles around the line at part (such that line passes through the centre of circles and line is perpendicular to circle surface), divide the circle circumference into portions, calculate coordinates of points on circumference,
In the image above, circle is divided into four portions and line is divided into two parts 0,1,2,3,4,5,6,7 are positions no (not coordinates). The below code calculates the coordinates:
for (int i =0; i <= lengthDivision; i++)
{
double y = minYCoor + i * dy;
for (int j = 0; j < circumferenceDivision; j++)
{
double t = j * dt;
mesh.Positions.Add(GetPosition(t, y));
}
}
Point3D GetPosition(double t, double y)
{
double x = Radius * Math.Cos(t);
double z = Radius * Math.Sin(t);
return new Point3D(x, y, z);
}
TriangleIndices
is collection of positions, WPF rendering system picks 3 positions from triangles indices in continuation and joins them to form a surface and render them (by following right hand thumb rule to decide front and back surface), in the figure above, 0 4 1 1 4 5 1 5 2 2 5 6 2 6 3 3 6 7 3 7 0 0 7 4 are triangle indices, by connecting positions in triangle indices surfaces are made. This code below connects positions to form triangles.
for (int i = 0; i < lengthDivision; i++)
{
for (int j = 0; j < circumferenceDivision; j++)
{
int x0 = j % circumferenceDivision + i * circumferenceDivision;
int x1 = (j + 1) % circumferenceDivision + i * circumferenceDivision;
int x2 = j + circumferenceDivision + i * circumferenceDivision;
int x3 = x1;
int x4 = x3 + circumferenceDivision;
int x5 = x2;
mesh.TriangleIndices.Add(x0);
mesh.TriangleIndices.Add(x2);
mesh.TriangleIndices.Add(x1);
mesh.TriangleIndices.Add(x3);
mesh.TriangleIndices.Add(x5);
mesh.TriangleIndices.Add(x4);
}
}
How to Make Cubes?
Cube
is MeshGeometry3D
formed by positions and triangle-indices, 0,1,2,3,4,5,6,7 are positions on a cube every point is relative to starting coordinate, just need to provide starting coordinates and widthheightdepth
to cube.cs, it will draw itself.
public static DependencyProperty StartingPointCubeProperty =
DependencyProperty.Register("StartingPointCube", typeof(Point3D), typeof(Base3D),
new PropertyMetadata(OnPoint3dChanged));
public Point3D StartingPointCube
{
get
{
return (Point3D)GetValue(StartingPointCubeProperty);
}
set
{
SetValue(StartingPointCubeProperty, value);
}
}
The magnet is illuminated in special way, there is point light inside it to give illumination.
PointLight light = new PointLight();
light.Position = new Point3D(point3d.X + widthHeightDepth,
point3d.Y + widthHeightDepth, point3d.Z + widthHeightDepth);
light.Color = Colors.Red;
modelGroup.Children.Add(light);
Placement of Cubes How?
In mesh, there are 125 empty blocks where cubes can be placed, imagine there are 5 floors, on each floor, there are 25 blocks (5 blocks in x direction * 5 blocks in z direction):
public static int BlocksInXdirection = 5;
public static int BlocksInZdirection = 5;
public static int NoofFloor = 5;
There are 12 cubes, 4 of each color (red,blue,green) and one moving cube magnet, these cubes are placed randomly in any of the 125 blocks, the below code does the same.
int xcoor, ycoor, zcoor;
int floorNo = -1;
int positionOnFloor = randomCube.Next(0, cubesPerFloor);
Random randomSteps = new Random();
for (int i = 1; i <= TotalCubes; i++)
{
positionOnFloor = randomCube.Next(0, cubesPerFloor);
Color color = ColorsCollection[i % 3];
floorNo = (floorNo + 3) % Constants.NoofFloor;
if (position[floorNo][positionOnFloor] == null)
{
xcoor = (int)(positionOnFloor % (Constants.BlocksInXdirection));
ycoor = floorNo;
zcoor = (int)(positionOnFloor / (Constants.BlocksInZdirection));
position[floorNo][positionOnFloor] = PlaceCube(xcoor, floorNo, zcoor, color);
}
}
We maintain each placed cube in data structure position
, position
is a dictionary whose keys are floor no and values are dictionary<int,cube>
keys of internal dictionary represents position on floor.
The code below creates Cube
at specified coordinate in 3D space.
private Cube PlaceCube(int xCoor, int yCoor, int zCoor, Color color)
{
double cubeLength =Constants.CubeLength;
Cube cube3d = new Cube();
cube3d.Transform = Translate;
cube3d.color = color;
cube3d.WidthHeightDepth = Constants.CubeLength;
cube3d.opacity = 1;
cube3d.StartingPointCube = new Point3D(cubeLength * xCoor,
cubeLength * yCoor,cubeLength * zCoor);
cubesCollection.Add(cube3d);
}
By default, the chosen position of magnet is floor 2 and 22 is position on floor.
this.Magnet = PlaceCube(Constants.MagnetBlockXDirection,
Constants.MagnetBlockYDirection, Constants.MagnetBlockZDirection, Colors.Yellow);
this.MagnetFloorNo = Constants.MagnetBlockYDirection;
this.MagnetPositionOnFloor = Constants.BlocksInXdirection *
Constants.MagnetBlockZDirection + Constants.MagnetBlockXDirection;
this.position[this.MagnetFloorNo][this.MagnetPositionOnFloor] = this.Magnet;
this.Magnet.IsMovingCube = true;
Movement of Magnet How?
Magnet
can push other cubes in direction of its movement, before magnet moves, we check if there is any vacant position in line of it movement.
Example 1: Let's say magnet moves left from current position 24 on floor no 1, it can move (if any one of positions 23,22,21,20 on the same floor is vacant).
Example 2: Let's say magnet moves back from current position 22 on floor no 1, it can move (if any one of positions 2,7,12,17 on same floor is vacant).
Example 3: Let's say magnet moves up from current position 10 on floor no 1, it can move (if any one of position 10 on floor no 2,3,4,5 is vacant), the below code snippet does the same.
case Direction.Up:
for (counter = MagnetFloorNo + 1; counter < Constants.NoofFloor; counter++)
{
if (position[counter][MagnetPositionOnFloor] == null)
{
emptyPositionOrFloor = counter;
canMove = true;
break;
}
}
If magnet can move, many things take place.
Camera position
, lookdirection
, fieldofview
are animated, the below code snippet does the same:
this.ViewModelCamera.BeginAnimation(PerspectiveCamera.PositionProperty,
animationKeyFramesCameraPosition, HandoffBehavior.Compose);
this.ViewModelCamera.BeginAnimation(PerspectiveCamera.LookDirectionProperty,
animationKeyFramesCameraLookDirection, HandoffBehavior.Compose);
this.ViewModelCamera.BeginAnimation(PerspectiveCamera.FieldOfViewProperty,
animationKeyFramesFieldofView, HandoffBehavior.Compose);
- On magnet movement,
datastructure
position
is updated (old position
s made empty and new position
s are filled), the below code snippet does the same.
case Direction.Left:
if (cubeLocation.X >= 0)
{
for (counter = emptyPositionOrFloor + 1; counter < MagnetPositionOnFloor; counter++)
{
this.MoveBlocks(position[MagnetFloorNo][counter], 1, Direction.Left);
position[MagnetFloorNo][counter - 1] = position[MagnetFloorNo][counter];
}
position[MagnetFloorNo][MagnetPositionOnFloor - 1] =
position[MagnetFloorNo][MagnetPositionOnFloor];
position[MagnetFloorNo][MagnetPositionOnFloor] = null;
MagnetPositionOnFloor--;
goto default;
}....
Magnet
movement is animated, the below code snippet does the same:
MovingCube.BeginAnimation(Cube.StartingPointCubeProperty,
animationKeyFrames, HandoffBehavior.Compose);
- When all
animation
stops, only then new movement of magnet can happen. The below code snippet ensures the same.
if (this.cubeAnimationCompleted == true &&
this.positionAnimationCompleted == true &&
this.cameraLookdirectionAnimationCompleted == true &&
this.fieldViewAnimationCompleted == true &&
CountMovingBlocks == 0)
The following events ensure that animation
has completed, only then new movements can take place:
private void AnimationKeyFramesCameraPosition_Completed(object sender, EventArgs e)
{
cameraLookdirectionAnimationCompleted = true;
}
private void AnimationKeyFramesCameraLookDirection_Completed(object sender, EventArgs e)
{
positionAnimationCompleted = true;
}
private void AnimationFieldView_Completed(object sender, EventArgs e)
{
fieldViewAnimationCompleted = true;
}
private void AnimationKeyFramesBox_Completed(object sender, EventArgs e)
{
cubeAnimationCompleted = true;
CheckCompletness();
}
MagnetPositiononFloor
, MagnetFloorNo
are updated on magnet
movement.
- Old events are unsubscribed and new events subscribed.
if (animationFieldView != null)
{
animationFieldView.Completed -= new EventHandler(AnimationFieldView_Completed);
}
animationFieldView = new DoubleAnimationUsingKeyFrames();
animationFieldView.Completed += new EventHandler(AnimationFieldView_Completed);...
Magnet
pushes other cubes, here cube3d
is instance of cube that is pushed by magnet
by number of steps in direction (Left
, Right
, Up
, Down
, Front
, Back
).
private void MoveBlocks(Cube cube3d, int step, Direction direction)....
- When
magnet
move pattern completeness is checked on completion of magnet animation as shown in event above.
private void CheckCompletness();
StepCount
is updated, tells how many steps magnet have moved, binded to view.
public int StepCount
{
get
{
return stepCount;
}
set
{
this.stepCount = value;
NotifyPropertyChanged("StepCount");
}
}
How to Pass keyboard Event with keyargs to ViewModel?
On keydown
event in main window, we want to invoke a function in viewmodel
and pass key args, for that Keyboard
event is binded to Delegate
command using System.Windows.Interactivity
DLL.
<i:Interaction.Triggers>
<i:EventTrigger EventName="KeyDown">
<local:InvokeDelegateCommandAction
Command="{Binding KeyDownCommand}"
CommandName="KeyDownCommand"
CommandParameter="{Binding RelativeSource={RelativeSource Self}, Path=InvokeParameter}" />
</i:EventTrigger> </i:Interaction.Triggers>
InvokeDelegateCommandAction
is custom trigger action for passing keyargs
, taken help from this Post.
How To Make Sure Cubes Are Visible When They Fall in Line of Sight?
On every move of magnet
, we calculate distance of others cubes from camera
position and maintain old distance.
private List<Cube> CalculateDistance(PerspectiveCamera camera)
{
Cube cube;
Vector3D vector;
List<Cube> cubeCollection = new List<Cube>();
for (int floor = 0; floor < Constants.NoofFloor; floor++)
{
for (int i = 0; i < Constants.NoofBlocksInXdirection *
Constants.NoofBlocksInZdirection; i++)
{
if (!((position[floor][i] == null) ||
(floor == MagnetFloorNo && i == MagnetPositionOnFloor)))
{
cube = position[floor][i] as Cube;
vector = Point3D.Subtract(camera.Position, cube.Point3DCircuit);
cube.OldDistanceFromViewer = cube.NewDistanceFromViewer;
cube.NewDistanceFromViewer = vector.Length;
cubeCollection.Add(cube);
}
}
}
return cubeCollection.OrderByDescending(x => x.NewDistanceFromViewer).ToList();
}
By differentiating between old distance and new distance opacity is changed, if cube has move farther from old position opacity is increased otherwise decreased.
Opacity is animated not changed in one go.
if ((cubeCollection[i].NewDistanceFromViewer -
cubeCollection[i].OldDistanceFromViewer) > 0)
{
oldOpacity = .8;
delta = (.1) / factor;
}
else
{
oldOpacity = 1;
delta = -(.2) / factor;
}
for (int count = 1; count < factor; count++)
{
newOpacity = oldOpacity + delta * count;
if (newOpacity > 0 && newOpacity <= 1)
{
LinearDoubleKeyFrame linearkeyFrame =
new LinearDoubleKeyFrame(newOpacity);
opacitykeyFrame.KeyFrames.Add(linearkeyFrame);
}
}
cubeCollection[i].BeginAnimation(Cube.opacityProperty, opacitykeyFrame,
HandoffBehavior.SnapshotAndReplace);
Why Do We Need to Animate position, look-direction, fieldofView Property of Camera?
To give perfect view of moving magnet in 3D mesh, there is need to animate camera
, when magnet
moves left/right/up/down/front/back camera
is moved left/right/up/down/front/back respectively to stay focused on magnet
.
There is special handling on front movement of magnet
fieldofview
is decreased and on back movement of cube fieldofview
is increased by fixed coordinates.
E.g., Let's say cube
has moved to left mesh block from current position, camera.position.x
coordinate is decreased by delta but camera.lookdirection.x
coordinate is increased by delta.
Note: We ensure the camera.lookdirection.magnitude
is constant while animating camera.position
, camera.lookdirection
and camera.fieldofview
.
How Do We Animate position, look-direction, fieldofView Property of Camera ?
Point3DAnimationUsingKeyFrames
, Vector3DAnimationUsingKeyFrames
, DoubleAnimationUsingKeyFrames
are used to animated position, look-direction, fieldofView
respectively.
Whichever way the magnet moves, e.g., cube moves in up direction by x coordinates same as cube length:
this.ViewModelCamera.BeginAnimation(PerspectiveCamera.PositionProperty,
animationKeyFramesCameraPosition, HandoffBehavior.Compose);
this.ViewModelCamera.BeginAnimation(PerspectiveCamera.LookDirectionProperty,
animationKeyFramesCameraLookDirection, HandoffBehavior.Compose);
this.ViewModelCamera.BeginAnimation(PerspectiveCamera.FieldOfViewProperty,
animationKeyFramesFieldofView, HandoffBehavior.Compose);
Following properties in view model are binded to dependency property of Perspective Camera
.
public Point3D CameraPosition
{
get
{
return this.cameraPosition;
}
set
{
this.cameraPosition = value;
NotifyPropertyChanged("CameraPosition");
}
}
public double FieldofView
{
get
{
return this.fieldofView;
}
set
{
this.fieldofView = value;
NotifyPropertyChanged("FieldofView");
}
}
public Vector3D CameraLookDirection
{
get
{
return this.cameraLookDirection;
}
set
{
this.cameraLookDirection = value;
NotifyPropertyChanged("CameraLookDirection");
}
}
Camera
property encapsulates the above properties and registers a Changed
event.
private PerspectiveCamera ViewModelCamera
{
get
{
if (camera == null)
{
camera = new PerspectiveCamera
(CameraPosition, CameraLookDirection,
new Vector3D(0, 0, 0), FieldofView);
camera.Changed += new EventHandler(Camera_changed);
}
return camera;
}
}
Whenever there is change in any of ViewModelCamera
properties, Changed
event is raised and we notify the MainWindow.xml to update itself.
MVVM Pattern?
Code is based on MVVM pattern, instance of MagnetViewModel
is set as datacontext
.
MagnetViewModel viewModel = new MagnetViewModel();
this.DataContext = viewModel;
The entire code was initially written in code behind but later moved to MVVM pattern to make code fully testable but still there is a little code in codebehind file which caters to addition and removal of cubes in ViewPort3d
. Addition of all cube to ViewPort3D
is a one time activity, removal of cube from ViewPort3D
happens once a pattern is made and cubes need to be removed from Viewport3D
.
void ViewModel_CollectionChangedChanged
(object sender, System.ComponentModel.PropertyChangedEventArgs e)
{
List<Cube> removeCubes = sender as List<Cube>;
for (int k = 0; k < removeCubes.Count; k++)
{
this.ViewPort3dPentagon.Children.Remove(removeCubes[k]);
}
}
CollectionChanged
events is raised from view model, (registered and listened) in code behind file to remove cube.
Points of Interest
- There could be many variants to this game, highlighted only the basic one.
- Most important thing to note performance of app in term of usability and system resource.
The most fun part is, it started with something and ended up with something else.
Do vote if you like this article. Cheers!
History
- 17th December, 2013: Initial version