Minimal DirectX demo

With the advent of .NET, Managed DirectX makes graphical programming easy under Windows. This program is a minimal DirectX demo with several interesting features:

  • Lighting
  • Perspective
  • Mouse control
  • Mesh rendering

Despite having all of these features, the entire program requires only 100 lines of source code to render a 3D teapot that can be rotated with the mouse:

This example program is freely available in two forms:

The program has several interesting aspects:

GUI

This is a Windows application that uses WinForms to create a window containing a DirectX device.

The WinForms-related code is written in an object oriented style, forming a Viewer class:

type Viewer = class
  inherit Form
  ...
end

This class encapsulates the creation and handling of a window containing a DirectX device. The constructor for this class accepts the title of the window as a string and a rendering function:

new(title, render) as form = {device=null; render=render; world=Matrix.Identity; drag=None} then
form.SetStyle(Enum.combine [ControlStyles.AllPaintingInWmPaint; ControlStyles.Opaque], true);
form.Text <- title;
form.MinimumSize <- form.Size;
form.Show()

Note that the ability to pass the rendering function as an argument to the constructor is functional programming.

Mouse control

Dragging is achieved by storing a mutable value in the Viewer object:

val mutable drag : (Matrix * int * int) option

When the scene is not being dragged, drag is None. During dragging, drag is Some(m, x, y) where m is the original world transformation matrix and x, y is the window coordinate where the drag started.

Inside the Viewer class, the OnMouseDown event is used to set drag:

override form.OnMouseDown e = form.drag <- Some(form.device.Transform.World, e.X, e.Y)

and the OnMouseUp event is used to reset drag:

override form.OnMouseUp e = form.drag <- None

The OnMouseMove event uses drag to update the world transformation matrix during a drag and then force redraw:

override form.OnMouseMove e = match form.drag with
  | Some(world, x, y) ->
      let scale = 5.f / float32(min form.device.Viewport.Width form.device.Viewport.Height) in
      form.world <- world * Matrix.RotationY(float32(x - e.X) * scale) * Matrix.RotationX(float32(y - e.Y) * scale);
      form.Invalidate()
  | None -> ()

This is a very succinct way to handle the mouse control of a scene. Note the use of variant types (option) and pattern matching.

Rendering

The OnPaint event of the window calls boiler-plate DirectX functions and uses the render function that the object was instantiated with to perform the actual rendering:

override form.OnPaint _ =
  if form.device = null then form.make_device();
  try
    form.device.BeginScene();
    form.device.Clear(Enum.combine [ClearFlags.Target; ClearFlags.ZBuffer], Color.Black, 1.f, 0);
    form.device.Transform.World <- form.world;
    form.render form.device;
    form.device.EndScene();
    form.device.Present()
  with _ -> form.make_device()

Note that this function is careful to replace the DirectX device if there is an error. This handles DirectX device loss and reset, although there are probably more elegant ways to do so. For example, if the render function threw one of our own exceptions, the device might not need replacing.

The render function, responsible for rendering the scene, is defined outside the Viewer class. The render function sets a light:

device.RenderState.SpecularEnable <- true;
device.Lights.Item(0).Specular <- Color.White;
device.Lights.Item(0).Direction <- new Vector3(-1.f, -1.f, 2.f);
device.Lights.Item(0).Update();
device.Lights.Item(0).Enabled <- true;

initialises the transformation matrices (representing perspective projection and the camera position and orientation relative to the scene):

let aspect = float32 device.Viewport.Width / float32 device.Viewport.Height in
device.Transform.Projection <- Matrix.PerspectiveFovLH(0.8f, aspect, 0.1f, 10.f);
device.Transform.View <- Matrix.LookAtLH(new Vector3(0.f, 0.f, -4.f), new Vector3(0.f, 0.f, 0.f), new Vector3(0.f, 1.f, 0.f));

sets materials propertes ready to render a red teapot with a white specular highlight:

let mutable material = new Material() in
material.Diffuse <- Color.Red;
material.Specular <- Color.White;
material.SpecularSharpness <- 32.f;
device.Material <- material;

and finally renders the built-in teapot mesh:

Idioms.using (Mesh.Teapot(device)) (fun teapot -> teapot.DrawSubset(0))

Note the use of Idioms.using to ensure that the teapot mesh is deallocated immediately. If the mesh is create and then left for garbage collection every frame, many meshes accumulate and stall the program when the garbage collector kicks in. In this case, a better practice is to store the mesh along with the device and invalidate it when the device is reset.

Despite the sophistication of this application, the entire program is tiny thanks to the expressiveness of the F# programming language.