Introduction
This article presents the simplest possible Silverlight/XNA program: a basic spinning triangle, albeit with full 3D lighting effects. It is the 3D equivalent of the classic ‘Hello World’ program. It comprises about 50 lines of code, and the rationale behind every line is explained.
Background
One of the biggest new features in Silverlight-5 is full GPU accelerated 3D graphics using the XNA framework.
The combination of Silverlight and XNA will enable a whole new class of 3D applications to run directly from the web, including applications in science, engineering, product marketing, business and the geo-spatial fields.
The XNA framework gives programmers a lot of power to control the graphics hardware in a fairly direct way, and some impressive results are possible. But while XNA simplifies 3D programming considerably, especially when compared to DirectX or OpenGL, it is still a relatively low-level technology and Silverlight programmers with no previous experience of 3D programming can face a steep initial learning curve. To make matters worse, because this is a new technology, the documentation is currently a bit patchy and the few samples that are available don’t always show the easiest ways to do things.
Getting Started
You will need to set up your Silverlight 5 development environment first and there is plenty of information available on the web already about that, so there’s no need to repeat it here.
Once you’re set up with the tools, here are the steps to build your first XNA application:
- Fire up Visual Studio and create a new Silverlight 5 application.
- Drop a ‘
DrawingSurface
’ control onto your main page. This is where all the 3D magic happens.
- If you can’t see the ‘
DrawingSurface
’ control in the toolbox, you may need to right-click and ‘Choose Items...’ to add it.
- Add a ‘
Draw
’ event to the Drawing Surface.
- In the event handler, add two lines of code:
GraphicsDevice g = GraphicsDeviceManager.Current.GraphicsDevice;
g.Clear(new Color(0.8f, 0.8f, 0.8f));
- Add two ‘
using
’ directives:
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
- Remove unnecessary ‘
using
’ directives, particularly System.Windows.Media
, otherwise you will have two conflicting definitions of Color
and the project won’t compile
- Add the following to the
<object>
element in your HTML or ASPX test page:
<param name="EnableGPUAcceleration" value="true" />
- Hit F5 and you should see a light gray rectangle in the browser.
Of course, this is no ordinary Silverlight gray rectangle, it’s a GPU accelerated XNA rectangle!
Easy enough so far, now let’s add some 3D.
Adding 3D
Here’s the code in its entirety:
using System.Windows.Controls;
using System.Windows.Graphics;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
namespace Silverlight5_3d
{
public partial class MainPage : UserControl
{
private float aspectRatio = 1f;
public MainPage()
{
InitializeComponent();
}
private void drawingSurface1_SizeChanged
(object sender, System.Windows.SizeChangedEventArgs e)
{
aspectRatio = (float)(drawingSurface1.ActualWidth / drawingSurface1.ActualHeight);
}
private void drawingSurface1_Draw(object sender, DrawEventArgs e)
{
GraphicsDevice g = GraphicsDeviceManager.Current.GraphicsDevice;
g.RasterizerState = RasterizerState.CullNone;
g.Clear(new Color(0.8f, 0.8f, 0.8f, 1.0f));
VertexPositionNormalTexture[] vertices = new VertexPositionNormalTexture[]{
new VertexPositionNormalTexture(new Vector3(-1, -1, 0),
Vector3.Forward,Vector2.Zero),
new VertexPositionNormalTexture(new Vector3(0, 1, 0),
Vector3.Forward,Vector2.Zero),
new VertexPositionNormalTexture(new Vector3(1, -1, 0),
Vector3.Forward,Vector2.Zero)};
VertexBuffer vb = new VertexBuffer(g,VertexPositionNormalTexture.VertexDeclaration,
vertices.Length,BufferUsage.WriteOnly);
vb.SetData(0, vertices, 0, vertices.Length, 0);
g.SetVertexBuffer(vb);
BasicEffect basicEffect = new BasicEffect(g);
basicEffect.EnableDefaultLighting();
basicEffect.LightingEnabled = true;
basicEffect.Texture = new Texture2D(g, 1, 1, false, SurfaceFormat.Color);
basicEffect.Texture.SetData<color>(new Color[1]{new Color(1f, 0, 0)});
basicEffect.TextureEnabled = true;
basicEffect.World = Matrix.CreateRotationY((float)e.TotalTime.TotalSeconds * 2);
basicEffect.View = Matrix.CreateLookAt(new Vector3(0, 0, 5.0f),
Vector3.Zero, Vector3.Up);
basicEffect.Projection = Matrix.CreatePerspectiveFieldOfView
(0.85f, aspectRatio, 0.01f, 1000.0f);
basicEffect.CurrentTechnique.Passes[0].Apply();
g.DrawPrimitives(PrimitiveType.TriangleList, 0, 1);
e.InvalidateSurface();
}
}
}
There's not too many lines of code, but there's a lot going on, so let's break it down.
Drawing Primitives
XNA is quite a low-level framework that works directly with the graphics hardware, so the only things that it knows how to draw are triangles and lines. In our program, the code that does the drawing is:
g.DrawPrimitives(PrimitiveType.TriangleList, 0, 1);
This instructs the GPU to draw a triangle using a pre-loaded buffer of vertices.
Vertex Buffers
XNA provides four built-in options for vertices and you can also define your own custom varieties. All vertices have a 3D position defined using a Vector3
structure, and they can have additional attributes such as color, texture coordinates and a lighting ‘normal’. We want our program to have realistic 3D lighting, so we use the VertexPositionNormalTexture
structure.
Our program has a single triangle so we create an array of three vertices. We define the 3D position coordinates so that the triangle lies on the X-Y plane, and we define the lighting ‘normals’ to be at right-angles to this, i.e. ‘forward’ along the Z-axis. We define the texture coordinates as (0,0) because we are going to use a one pixel flat colored texture bitmap.
Once we have our array of vertices, we wrap it in a VertexBuffer
class and send this to the GPU.
VertexPositionNormalTexture[] vertices = new VertexPositionNormalTexture[]{
new VertexPositionNormalTexture(new Vector3(-1, -1, 0),Vector3.Forward,Vector2.Zero),
new VertexPositionNormalTexture(new Vector3(0, 1, 0),Vector3.Forward,Vector2.Zero),
new VertexPositionNormalTexture(new Vector3(1, -1, 0),Vector3.Forward,Vector2.Zero)};
VertexBuffer vb = new VertexBuffer
(g,VertexPositionNormalTexture.VertexDeclaration,
vertices.Length,BufferUsage.WriteOnly);
vb.SetData(0, vertices, 0, vertices.Length, 0);
g.SetVertexBuffer(vb);
The GraphicsDevice Class
The GraphicsDevice
class is central to XNA, it is used to control the GPU. There is only one instance of it, which you get through a static
property of the GraphicsDeviceManager
class.
The GraphicsDevice
class has methods for drawing primitives and for setting up vertex buffers as covered above. It also has quite a few other properties and methods for controlling the details of the 3D rendering process. For our simple program, we leave everything with its default settings, except for ‘RasterizerState
’ which we change to ‘CullNone
’. The default is not to draw the back side of triangles, because typically they are out of sight on the inside of 3D objects, but our 3D model is a single triangle and we want to be able to see both sides of it.
GraphicsDevice g = GraphicsDeviceManager.Current.GraphicsDevice;
g.RasterizerState = RasterizerState.CullNone;
g.Clear(new Color(0.8f, 0.8f, 0.8f, 1.0f));
Effects
XNA offers a choice of five different ‘Effect
’ classes, which wrap up a lot of the low level details of talking to the GraphicsDevice
. We’ll use the ‘BasicEffect
’ class, which is actually quite powerful - it includes a very nice 3D lighting set-up as standard.
BasicEffect basicEffect = new BasicEffect(g);
basicEffect.EnableDefaultLighting();
basicEffect.LightingEnabled = true;
For ultimate low-level control, it is possible to program XNA without using the built-in Effect classes by working directly with the GraphicsDevice
object. You can even program your own custom pixel shaders and vertex shaders. But the built in Effect
classes, particularly the BasicEffect
, make things a lot easier.
Textures
In XNA, 3D scenes are made up of triangle meshes overlaid with textures. Textures are simply 2D bitmaps – the XNA class is Texture2D
. They are sent to the GraphicsDevice
using the Effect
class and each triangle vertex specifies its 2D coordinates within the texture. By convention, these texture coordinates are called U and V.
In many XNA applications, particularly games, textures are created offline and then loaded in at run-time. For our simple program, we programmatically create a one pixel texture of solid red.
basicEffect.Texture = new Texture2D(g, 1, 1, false, SurfaceFormat.Color);
basicEffect.Texture.SetData<color>(new Color[1]{new Color(1f, 0, 0)});
basicEffect.TextureEnabled = true;
</color>
Matrices and 3D Transformations
This is the heart of 3D programming. The vertices in our vertex buffer define the locations of the three corners of the triangle within our 3D virtual world, but these 3D coordinates need to be converted into 2D screen coordinates before anything can be displayed.
There are three stages to this process and each stage uses a 3D transformation defined using the Matrix
class. The Matrix
class actually represents a 4 x 4 matrix of floating point numbers, but its primary function is to transform 3D coordinates, defined by the Vector3
class, through the basic operations of translation, rotation and scaling.
basicEffect.World = Matrix.CreateRotationY((float)e.TotalTime.TotalSeconds * 2);
basicEffect.View = Matrix.CreateLookAt(new Vector3(0, 0, 5.0f), Vector3.Zero, Vector3.Up);
basicEffect.Projection = Matrix.CreatePerspectiveFieldOfView
(0.85f, aspectRatio, 0.01f, 1000.0f);
Step one is to convert from model coordinates to world coordinates. This is called the World Transform. In our program, we have one model, a single triangle, and the World Transform is used to define where it is and how it is oriented in our 3D virtual world. If our 3D scene had several models, or several instances of the same model, they would each have their own World Transform. We just have one model, but it does rotate in real time. So we use a World Transform to represent a rotation based on elapsed time.
Step two is to convert from world coordinates which are centred on the ‘origin’ of our virtual world, to view coordinates, which are centred on our view point or camera position. This is called the View Transform. The Matrix
class has a convenient method (CreateLookAt
) for creating a View Transform based on the camera position and the center point of what it's looking at.
Step three is to convert from the 3D coordinates in our virtual world, to 2D screen coordinates. For consistency, this step is also done using the Matrix
class, so the output is 3D coordinates, but only the X and Y values are used for creating the 2D rendered image on the screen. This step is called the Projection Transform and XNA offers two choices: orthogonal and perspective. With the orthogonal option, objects further from the camera do not appear smaller. The perspective option is the more ‘natural’ view where distant objects appear smaller. The Matrix class also provides a convenient method (CreatePerspectiveFieldOfView
) for creating the Projection Transform based on the camera properties, in particular the lens angle (i.e. wide angle or telephoto) expressed as radians in the Y axis.
Once our three transformation matrices are set up, the BasicEffect
class takes care of setting up the GraphicsDevice
to render our 3D scene onto the 2D screen.
Effect Passes and Techniques
All XNA’s built-in Effect
classes inherit from the Effect
base class, which encapsulates the concepts of ‘Techniques’ and ‘Passes’.
Techniques provide alternative rendering effects that can be selected at draw-time, maybe depending on the type of object being drawn.
Passes are used for multi-stage rendering.
The BasicEffect
class offers a single technique, which is selected by default, and which has a single pass. It is necessary to ‘Apply’ a pass before you issue any drawing commands. Our program does that with this line of code:
basicEffect.CurrentTechnique.Passes[0].Apply();
But you will often see XNA code of this form:
foreach (EffectPass pass in effect.CurrentTechnique.Passes)
{
pass.Apply();
}
We use the simpler form because we know that the BasicEffect
only has one Pass. If we were using an effect with more than one pass, or if we wanted to generalise our code so that it would work with any Effect
class, we would use the foreach
loop.
The Resize Event and the New Silverlight 5 Threading Model
When we create the Projection Transform, we need to provide the aspect ratio (width/height) of the DrawingSurface
.
To keep things simple for our ‘Hello World’ program, we might try to do this in our Draw
event handler. But this will not work:
Float aspectRatio = (float)(drawingSurface1.ActualWidth / drawingSurface1.ActualHeight);
basicEffect.Projection =
Matrix.CreatePerspectiveFieldOfView(0.85f, aspectRatio, 0.01f, 1000.0f);
It throws an exception:
System.UnauthorizedAccessException: Invalid cross-thread access
The reason is Silverlight 5's new threading model. Previously, Silverlight did everything on its single UI thread, although you did have the option of creating additional threads manually.
Silverlight 5 now does its low-level graphics work on a different thread called the ‘Composition
’ thread. For XAML based Silverlight, you don’t need to worry about this change because your code operates on the XAML object tree and isn't involved in the low-level rendering of this.
However, with Silverlight-XNA, you are working at a lower level and the Draw
event of the DrawingSurface
control executes on the composition thread. If you try to access XAML controls from the composition thread, you will get an exception.
In our program, we avoid this with the local ‘aspectRatio
’ variable, which we update in the Resize
event handler and then use in the Draw
event handler. We don’t do any locking on this variable, on the assumption that a 32 bit float is atomic.
For production code that shares more complex data structures between the composition thread and the UI thread, you will need to be very careful to lock things correctly.
Invalidate
The final line of code in the program is the call to:
e.InvalidateSurface();
Putting this call at the end of the Draw
event handler causes the Draw
event to be immediately raised again. For a 3D scene that is constantly changing - like our rotating triangle - this gives the smoothest operation.
If your scene is static
, then you don't need to constantly redraw it, and you can just call InvalidateSurface()
when something changes.
Optimising the Draw Method
Our program is designed to be as short and simple as possible to illustrate the essentials of Silverlight-XNA.
In a real program, you will want to optimise the critical Draw
handler by separating out the one-time setup code. The Draw
handler executes every frame, e.g., 60 time a second, so you need to keep it as light as possible. In particular, you should avoid instantiating any objects in there.
Conclusion
That completes our run through of a very simple Silverlight-XNA program.
Although it covers just a small fraction of what's available in XNA, a lot of the key concepts are in there and you could actually build quite a complex 3D program using just the classes and methods illustrated here.
Addendum
Since this article was written, Microsoft has released Silverlight 5.
The released version has tighter security constraints than the beta test version and the user now has to explicitly grant permission for a web site to use accelerated 3D graphics.
You need to add the following code to the main page constructor to prompt the user to do this:
public MainPage()
{
InitializeComponent();
if (GraphicsDeviceManager.Current.RenderMode != RenderMode.Hardware)
{
string message;
switch (GraphicsDeviceManager.Current.RenderModeReason)
{
case RenderModeReason.Not3DCapable:
message = "You graphics hardware is not capable of displaying this page ";
break;
case RenderModeReason.GPUAccelerationDisabled:
message = "Hardware graphics acceleration has not been enabled
on this web page.\n\n" +
"Please notify the web site owner.";
break;
case RenderModeReason.TemporarilyUnavailable:
message = "Your graphics hardware is temporarily unavailable.\n\n"+
"Try reloading the web page or restarting your browser.";
break;
case RenderModeReason.SecurityBlocked:
message =
"You need to configure your system to allow this web site
to display 3D graphics:\n\n" +
" 1. Right-Click the page\n" +
" 2. Select 'Silverlight'\n" +
" (The 'Microsoft Silverlight Configuration' dialog will be displayed)\n" +
" 3. Select the 'Permissions' tab\n" +
" 4. Find this site in the list and change its 3D Graphics
permission from 'Deny' to 'Allow'\n" +
" 5. Click 'OK'\n" +
" 6. Reload the page";
break;
default:
message = "Unknown error";
break;
}
MessageBox.Show(message,"3D Content Blocked", MessageBoxButton.OK);
}
}
Unfortunately, Microsoft has not provided good support for making an easy user experience for this.