# Trying to understand 2D vector -> projection space conversion

I'm trying to understand how I can transform 2D vectors (x, y) to projection space. Something hasn't quite clicked for me yet. As per my previous question, here's the effect I'm trying to achieve:

My object model is entirely in 2D world space, but I want to render it as per above. That is, position the camera above at about 45 degrees looking down on my scene. Objects further away will appear smaller and move towards the center of the screen.

In an effort to visualize this, I put together a WPF/XNA application that renders a bunch of dots in world, view, and projection spaces. The parameters for the view and projection matrices can be altered in real-time and the resulting projections update accordingly.

However, things aren't being rendered as I expected/hoped. Here's what I see:

I was expecting to see the projection space render a road off into the distance similar to the earlier screenshot.

Can someone take a look at the code and see what it is I'm doing wrong? Apologies for the wall of code, but I didn't want to leave anything out as I may have done something stupid anywhere.

Vector2Collection:

namespace PerspectiveTest
{
using System.Collections.ObjectModel;
using Microsoft.Xna.Framework;

public sealed class Vector2Collection : ObservableCollection<Vector2>
{
}
}


VectorSurface:

using System.Linq;
using System.Windows;
using System.Windows.Media;
using XNA = Microsoft.Xna.Framework;

namespace PerspectiveTest
{
public sealed class VectorSurface : FrameworkElement
{
public static readonly DependencyProperty BrushProperty = DependencyProperty.Register(
"Brush",
typeof(Brush),
typeof(VectorSurface),

public static readonly DependencyProperty MatrixProperty = DependencyProperty.Register(
"Matrix",
typeof(XNA.Matrix),
typeof(VectorSurface),

public static readonly DependencyProperty VectorsProperty = DependencyProperty.Register(
"Vectors",
typeof(Vector2Collection),
typeof(VectorSurface),

public Brush Brush
{
get { return (Brush)this.GetValue(BrushProperty); }
set { this.SetValue(BrushProperty, value); }
}

public XNA.Matrix Matrix
{
get { return (XNA.Matrix)this.GetValue(MatrixProperty); }
set { this.SetValue(MatrixProperty, value); }
}

public Vector2Collection Vectors
{
get { return (Vector2Collection)this.GetValue(VectorsProperty); }
set { this.SetValue(VectorsProperty, value); }
}

protected override void OnRender(DrawingContext drawingContext)
{
base.OnRender(drawingContext);

if (this.Vectors == null)
{
return;
}

var matrix = this.Matrix;
var transformed = new XNA.Vector3[this.Vectors.Count];
var vectors3 = this.Vectors.Select(x => new XNA.Vector3(x.X, x.Y, 0)).ToArray();
XNA.Vector3.Transform(vectors3, ref matrix, transformed);

foreach (var vector in transformed)
{
drawingContext.DrawEllipse(this.Brush, null, vector.ToWpfPoint(), 1, 1);
}
}

private static void OnPropChanged(DependencyObject dependencyObject, DependencyPropertyChangedEventArgs e)
{
((VectorSurface)dependencyObject).InvalidateVisual();
}
}

public static class PointExtensions
{
public static Point ToWpfPoint(this XNA.Vector2 vector)
{
return new Point(vector.X, vector.Y);
}

public static Point ToWpfPoint(this XNA.Vector3 vector)
{
return new Point(vector.X, vector.Y);
}
}
}


MainWindow.xaml.cs:

using System.ComponentModel;
using System.Windows;
using XNA = Microsoft.Xna.Framework;

namespace PerspectiveTest
{
public partial class MainWindow : Window, INotifyPropertyChanged
{
private XNA.Vector3 cameraPosition;
private XNA.Vector3 cameraTarget;
private XNA.Vector3 cameraUp;
private float fieldOfView;
private float aspectRatio;
private float nearPlaneDistance;
private float farPlaneDistance;

public event PropertyChangedEventHandler PropertyChanged;

public Vector2Collection Vectors
{
get { return this.vectors; }
}

public XNA.Vector3 CameraPosition
{
get { return this.cameraPosition; }
set
{
if (this.cameraPosition != value)
{
this.cameraPosition = value;
this.OnPropertyChanged("CameraPosition");
this.CreateViewMatrix();
}
}
}

public XNA.Vector3 CameraTarget
{
get { return this.cameraTarget; }
set
{
if (this.cameraTarget != value)
{
this.cameraTarget = value;
this.OnPropertyChanged("CameraTarget");
this.CreateViewMatrix();
}
}
}

public XNA.Vector3 CameraUp
{
get { return this.cameraUp; }
set
{
if (this.cameraUp != value)
{
this.cameraUp = value;
this.OnPropertyChanged("CameraUp");
this.CreateViewMatrix();
}
}
}

public float FieldOfView
{
get { return this.fieldOfView; }
set
{
if (this.fieldOfView != value)
{
this.fieldOfView = value;
this.OnPropertyChanged("FieldOfView");
this.CreateProjectionMatrix();
}
}
}

public float AspectRatio
{
get { return this.aspectRatio; }
set
{
if (this.aspectRatio != value)
{
this.aspectRatio = value;
this.OnPropertyChanged("AspectRatio");
this.CreateProjectionMatrix();
}
}
}

public float NearPlaneDistance
{
get { return this.nearPlaneDistance; }
set
{
if (this.nearPlaneDistance != value)
{
this.nearPlaneDistance = value;
this.OnPropertyChanged("NearPlaneDistance");
this.CreateProjectionMatrix();
}
}
}

public float FarPlaneDistance
{
get { return this.farPlaneDistance; }
set
{
if (this.farPlaneDistance != value)
{
this.farPlaneDistance = value;
this.OnPropertyChanged("FarPlaneDistance");
this.CreateProjectionMatrix();
}
}
}

public MainWindow()
{
InitializeComponent();
this.vectors = new Vector2Collection();
this.PopulateVectors();

this.cameraPosition = new XNA.Vector3(75, 200, 150);
this.cameraTarget = new XNA.Vector3(75, 0, 0);
this.cameraUp = new XNA.Vector3(0, 1, 0);
this.fieldOfView = XNA.MathHelper.Pi / 4;
this.aspectRatio = 1f;
this.nearPlaneDistance = 1f;
this.farPlaneDistance = 200f;

this.CreateViewMatrix();
this.CreateProjectionMatrix();
this.DataContext = this;
}

private void PopulateVectors()
{
for (var i = 0; i < 150; i += 3)
{
}
}

private void CreateViewMatrix()
{
this.viewSpace.Matrix = XNA.Matrix.CreateLookAt(this.cameraPosition, this.cameraTarget, this.cameraUp);
this.CreateProjectionMatrix();
}

private void CreateProjectionMatrix()
{
var viewMatrix = this.viewSpace.Matrix;
var perspectiveMatrix = XNA.Matrix.CreatePerspectiveFieldOfView(this.fieldOfView, this.aspectRatio, this.nearPlaneDistance, this.farPlaneDistance);
XNA.Matrix combinedMatrix;
XNA.Matrix.Multiply(ref viewMatrix, ref perspectiveMatrix, out combinedMatrix);
this.projectionSpace.Matrix = combinedMatrix;
}

private void OnPropertyChanged(string propertyName)
{
var handler = this.PropertyChanged;

if (handler != null)
{
handler(this, new PropertyChangedEventArgs(propertyName));
}
}
}
}


MainWindow.xaml:

<Window x:Class="PerspectiveTest.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:PerspectiveTest"
Title="MainWindow"
SizeToContent="WidthAndHeight">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>

<Label FontWeight="Bold" HorizontalAlignment="Center">World Space</Label>
<Label Grid.Column="1" HorizontalAlignment="Center" FontWeight="Bold">View Space</Label>
<Label Grid.Column="2" HorizontalAlignment="Center" FontWeight="Bold">Projection Space</Label>

<Border Grid.Row="1" BorderThickness="1" BorderBrush="Gray" Margin="50, 10, 50, 50">
<local:VectorSurface x:Name="worldSpace" Vectors="{Binding Vectors}" Brush="Black" Width="150" Height="150"/>
</Border>
<Border Grid.Row="1" Grid.Column="1" BorderThickness="1" BorderBrush="Gray" Margin="50, 10, 50, 50">
<local:VectorSurface x:Name="viewSpace" Vectors="{Binding Vectors}" Brush="Red" Width="150" Height="150"/>
</Border>
<Border Grid.Row="1" Grid.Column="2" BorderThickness="1" BorderBrush="Gray" Margin="50, 10, 50, 50">
<local:VectorSurface x:Name="projectionSpace" Vectors="{Binding Vectors}" Brush="Green" Width="150" Height="150"/>
</Border>

<Label Grid.Row="2">Camera Position:</Label>
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding CameraPosition}"/>

<Label Grid.Row="3">Camera Target:</Label>
<TextBox Grid.Row="3" Grid.Column="1" Text="{Binding CameraTarget}"/>

<Label Grid.Row="4">Camera Up:</Label>
<TextBox Grid.Row="4" Grid.Column="1" Text="{Binding CameraUp}"/>

<Label Grid.Row="5">Field of View:</Label>
<TextBox Grid.Row="5" Grid.Column="1" Text="{Binding FieldOfView}"/>

<Label Grid.Row="6">Aspect Ratio:</Label>
<TextBox Grid.Row="6" Grid.Column="1" Text="{Binding AspectRatio}"/>

<Label Grid.Row="7">Near Plane Distance:</Label>
<TextBox Grid.Row="7" Grid.Column="1" Text="{Binding NearPlaneDistance}"/>

<Label Grid.Row="8">Far Plane Distance:</Label>
<TextBox Grid.Row="8" Grid.Column="1" Text="{Binding FarPlaneDistance}"/>
</Grid>
</Window>


You seem to be under the misconception that you can apply a projection matrix the same way as you apply a world or view matrix, i.e. just by multiplying a position in world or view space by it. That's not how it works!

To put it simply, when applying the projection matrix, the w component is relevant, so you need to pass it a 4D vector (x,y,z,1) and call Vector4.Transform instead. Afterwards you also need to homogenize the result (i.e. convert from 4D back to 3D) by dividing every component by w.

Example

// Position and combined matrix
Vector3 position = new Vector3(x,y,z);
Matrix matrix = world * view * projection;

// Apply matrix
Vector4 result = Vector4.Transform(new Vector4(position, 1), matrix);

// Homogenize result
result /= result.w;

// Show result
DrawEllipseAt(result.X, result.Y);


But you should also realize that the resulting coordinates are in normalized clip space with x/y coordinates ranging from -1 to 1 and the z coordinate ranging from 0 to 1.

Your coordinates might also have received a non-uniform scale by the projection in order to match the aspect ratio you chose when creating the projection matrix, so make sure you use sensible values, i.e. it should match the area in which you'd like to draw.

Finally you still need to convert the points from clip space to screen space. This is a simple mapping from the -1 to 1 range into your viewport.

Example

float halfWidth = viewport.Width / 2f;
float halfHeight = viewport.Height / 2f;
Vector2 screenResult;
screenResult.X = result.X * halfWidth + halfWidth + viewport.X;
screenResult.Y = -result.Y * halfHeight + halfHeight + viewport.Y;


Note: If your Z values are all zero you won't see anything moving "into the distance". You need different Z values for any perspective to take place!

• This is brilliant - thanks. I have it working now thanks to your explanation. And "misconception" was an awfully kind way to tell me I don't know what the hell I'm doing - much appreciated ;) – me-- Mar 10 '12 at 16:56
• @David. Would it be accurate to say that accounting for the w component and subsequently homongenizing the result typically goes on in the rasterizer function (or at least post vertex shader) of the GPU when using effects? I ask because I didn't initially clue into the main issue here and suspect it happens automatically when using shaders. – Steve H Mar 10 '12 at 17:14
• @user13414 No problem, it's really easy to miss this fact unless you've read about it before in some book, due to the fact that pretty much every other type of matrix that we use doesn't modify the W value. So we tend to ignore the fourth dimension and that we're actually working with homogenous coordinates. – David Gouveia Mar 10 '12 at 17:17
• @SteveH I've checked a few sources and the information seems consistent - division by W happens automatically during the rasterizer state, after clipping. I find that detail intriguing because I've always thought that clipping happened against the -1/1 planes, but since it's performed before homogenizing, I'm guessing it actually happens against the -W/W planes instead. Any confirmation on this? Is it to save all those extra divisions for vertices that would end up clipped anyway? – David Gouveia Mar 10 '12 at 17:41
• It would appear the 2nd paragraph indicates the data does come into the rasterizer already homogenized (in -1/1 space) before clipping. So it would be clipped against the -1/1 planes as you thought. The 1st paragraph mentions dividing by z (not w) just to provide the down scaling of distant objects to make them appear smaller. This is a dx10 page so I don't know if it's different for dx9., prolly not. – Steve H Mar 10 '12 at 20:42

Based on your camera's position and orientation, the object appearing smaller and farther away has to have a Z component value in the pre-transformed state that is less than the Z value of the closer object. Since both objects are the same height above the road, their Y component values in the pre-transformed state should be equal.

Where you convert your vectors from Vector2s to Vector3s, Your Vector2.Y should probably go to Vector3.Z. All Vector3.Ys should be 0 (or at least equal)...

The road in the screenshot goes off into the distance in the -Z direction (in 3dWorldSpace ref) (based on your camera position/target). So the farther away points should have a decreasing Z value in 3dworld space. Your pre-transformed points should be in 3dWorldSpace but it almost seems like you are trying to set your point values (PopulateVectors) in 2d space. That looks odd to me.

• Another way of explaining this is that your combinedMatrix (a view * projection) is designed to transform a point from world space to screen (projection) space. But it is designed to assume the point is in 3d world space, not any sort of 2d world space. Your PopulateVectors() seems to live in a hybrid reference system where the X component may relate to 3d world space (symetrically straddling camera alignment) but the Y relates to some sort of 2d space like a God view (looking directly down on an orthogonal projection). Can't mix reference systems on a per component basis. – Steve H Mar 10 '12 at 16:54
• Yes, my pre-transformed points are in 2D space - all objects are at elavation zero (some objects in my game may jump but I'll get to that when I get to it). Your comment about placing Y in Z, in particular, is definitely helpful. That had thrown me off quite a bit. Thanks! – me-- Mar 10 '12 at 16:54
• Just saw your comment. My 2D space is indeed top-down, or "God" view as you say. Though I'm an atheist, so I'm gonna stick with top-down ;) – me-- Mar 10 '12 at 16:55