Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

3D Basics using Silverlight-5 and XNA

0.00/5 (No votes)
12 Dec 2011 1  
A minimal 3D program showing how to use XNA in Silverlight-5, with a full explanation of the core concepts

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.

screenshot.png

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:

  1. Fire up Visual Studio and create a new Silverlight 5 application.
  2. Drop a ‘DrawingSurface’ control onto your main page. This is where all the 3D magic happens.
  3. If you can’t see the ‘DrawingSurface’ control in the toolbox, you may need to right-click and ‘Choose Items...’ to add it.
  4. Add a ‘Draw’ event to the Drawing Surface.
  5. In the event handler, add two lines of code:
    GraphicsDevice g = GraphicsDeviceManager.Current.GraphicsDevice;
    g.Clear(new Color(0.8f, 0.8f, 0.8f));
  6. Add two ‘using’ directives:
    using Microsoft.Xna.Framework;
    using Microsoft.Xna.Framework.Graphics;
  7. Remove unnecessary ‘using’ directives, particularly System.Windows.Media, otherwise you will have two conflicting definitions of Color and the project won’t compile
  8. Add the following to the <object> element in your HTML or ASPX test page:
    <param name="EnableGPUAcceleration" value="true" />
  9. 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();
  // Drawing commands
}

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.

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here