Saturday, 5 December 2009

Plotting a surface using WPF 3D from F#

One of the features that drew me to F# was the ease of interoperability with code written in other CLR based languages, allowing me to easily re-use existing code with F# as a concise and expressive glue language to write the core of the application – something I’ve used IronPython for in the past.

Continuing with the option pricing theme, I’m going to put together an application to visualize the sensitivity of the option price to the various inputs in the pricing. Windows Presentation Foundation seemed to be an easy way of achieving this, so this post walks through the creation of a small F# application to plot a function of two variables as a 3D surface.

Getting started

Being familiar with 3D graphics, but never having used WPF from F# before (or indeed any other language) it took me a little while to get my bearings. After creating a new F# project the following steps were necessary before I could use get going:

  • Add references to WindowsBase, PresentationCore, PresentationFramework and System.Xml
  • Downloaded 3D Tools from and added a reference to that library

3DTools provides two pieces of functionality that will be used in this application – a trackball that allows the camera to be rotated by clicking and dragging in the viewport, and a mechanism by which to draw lines of constant screen-space width (ScreenSpaceLines3D).

Constructing an approximation of the surface

The API for displaying a surface will accept a set of points on the X-axis, a second set of points on the Z-axis and a function y = f(x,z) to calculate the Y-axis displacement for each (X,Z) point. For each (Xi, Zi) two triangles will be created in the square made up of (Xi, Zi), (Xi+1,Zi), (Xi+1,Zi+1) and (Xi,Zi+1), e.g.

The following code constructs a mesh as described above:

/// Evaluates the surface defined by f at each point in the cartesian 
/// product of xs and zs. xs and zs are required to be ordered in ascending
/// numerical order.
let EvaluateSurface (xs:float array) (zs:float array) (f: float -> float -> float) =
    if zs.Length < 2 then invalidArg "zs" "Cannot construct surface with fewer than two points on the z-axis"
    if xs.Length < 2 then invalidArg "xs" "Cannot construct surface with fewer than two points on the x-axis"

    // Find the smallest and largest value on each axis; the members of 
    // each axis array must be passed in ascending order.
    let xMin   = xs.[0]
    let xMax   = xs.[xs.Length-1]
    let xRange = xMax - xMin
    let zMin   = zs.[0]
    let zMax   = zs.[zs.Length-1]
    let zRange = zMax - zMin

    // Evaluate the function f for each pairing of points in xs and ys. Can
    // be done with List.iter but the imperative form generates simpler, 
    // more efficient IL
    let points  = Point3DCollection()
    let mutable yMin = Double.MaxValue
    let mutable yMax = Double.MinValue
    for x in xs do
        for z in zs do
            let y = f x z
            if y < yMin then yMin <- y elif y > yMax then yMax <- y
            points.Add(Point3D(x, y, z))
    
    // Reposition the points in object space so (yMax + yMin)/2 is 
    // positioned at y=0, and similarly for the X and Y axes.
    let yRange      = yMax - yMin
    let translation = Vector3D( -(xMax+xMin)/2.0, -(yMax+yMin)/2.0, -(zMax+zMin)/2.0 )
    let scale       = Vector3D( 1.0 / xRange, 1.0 / yRange, 1.0 / zRange )
    let texture     = PointCollection()
    for i in 0..points.Count-1 do
        let p = points.[i]
        points.[i] <- Point3D( (p.X + translation.X) * scale.X,
                               (p.Y + translation.Y) * scale.Y,
                               (p.Z + translation.Z) * scale.Z ) 
        texture.Add(Point((p.Y-yMin)/yRange, 0.0))
    
    // We now have |xs| * |zs| points. We need to form a triangle mesh 
    // over the top. For each index i, we construct two triangles from 
    // points (i, i+1, i+|xs|) and (i+1, i+|xs|, i+|xs|+1)
    let numSquares = xs.Length * (zs.Length-1)
    let triangles  = Int32Collection(numSquares*6)
    for i in 0..zs.Length-2 do
        let row = i * xs.Length
        for j in 0..xs.Length-2 do
            // For convenience of notation, give the indices of the four
            // corners of the square labels.
            let bl = row+j
            let br = bl+1     
            let tl = bl+xs.Length  
            let tr = tl+1      
            // Construct two triangles (bl, tl, br) and (tl, tr, br)
            triangles.Add(bl)
            triangles.Add(tl)
            triangles.Add(br)
            triangles.Add(tl)
            triangles.Add(tr)
            triangles.Add(br)
            
    let mesh = MeshGeometry3D()
    mesh.Positions          <- points
    mesh.TextureCoordinates <- texture
    mesh.TriangleIndices    <- triangles
    mesh

Adding contour lines

To make it easy to see the relative height of different points on the surface and to highlight the shape of the surface, it will be coloured according to its displacement on the Y-axis. An easy way of doing this is by assigning UV texture coordinates to each point based on the ratio of the Y coordinate to the Y-axis range - (Y-YMIN)/(YMAX-YMIN) – as seen in the code above. A GradientBrush can then be used to specify the colour for each Y value. Contour lines can be added by adding thin bands of a single colour to the gradient brush. The following code will construct such a brush:

/// Creates a gradient brush containing equally-sized stripes of each 
/// colour in the list, separated by a thin gray contour line. If the 
/// blend parameter is true then each band will blend smoothly from one
/// colour to the next. If the blend parameter is false then each band
/// will be a solid colour.
let CreateGradientBrush (stripes:int list) blend = 
    /// Utility method to convert from an integer xxRRGGBB representation 
    /// to a WPF Color
    let inline ColorFromRRGGBB hex =
        let r = byte((hex >>> 16) &&& 0xff)
        let g = byte((hex >>> 8) &&& 0xff)
        let b = byte(hex &&& 0xff)
        Color.FromRgb(r, g, b)

    let gradient = LinearGradientBrush()
    gradient.StartPoint <- Point(0.0, 0.5)
    gradient.EndPoint   <- Point(1.0, 0.5)
        
    // A small change in texture space used to create a visually sharp
    // edge between two adjacent colours that shouldn't appear blended
    // into each other.
    let epsilon    = 0.0000001 
    let lineWidth  = 0.003
    let lineColour = Color.FromRgb(0x66uy, 0x66uy, 0x66uy)
        
    let limit = float(stripes.Length)    
    for i in 0..stripes.Length-2 do
        let c   = ColorFromRRGGBB stripes.[i]
        let c'  = if blend then ColorFromRRGGBB stripes.[i+1] else c
        let bottom   = float(i) / limit
        let top      = (float(i+1) / limit) - epsilon
        let lineMin  = top - lineWidth
        let colorMax = lineMin - epsilon
        gradient.GradientStops.Add(GradientStop(c,  bottom))
        gradient.GradientStops.Add(GradientStop(c', colorMax))
        gradient.GradientStops.Add(GradientStop(lineColour, lineMin))
        gradient.GradientStops.Add(GradientStop(lineColour, top))

    // Handle the final colour separately so that there isn't a gray
    // contour line at the top - it just finishes in the final colour.
    let numStops   = gradient.GradientStops.Count
    let lastOffset = gradient.GradientStops.[numStops-1].Offset
    let topColour  = ColorFromRRGGBB stripes.[stripes.Length-1]
    gradient.GradientStops.Add(GradientStop(topColour, lastOffset+epsilon))
    gradient.GradientStops.Add(GradientStop(topColour, 1.0))
    gradient

Rendering the mesh

Having constructed the model and the texture that we are going to apply, then there are not many more steps required to render it on the screen:

1. Apply the gradient to the mesh

To apply the gradient to the mesh, we need to create a GeometryModel3D that binds together the mesh with the material. In order for both sides of the mesh to be rendered it is necessary to set both the front and the back material:

let mesh     = EvaluateSurface xs ys f
let gradient = CreateGradientBrush [0x62a4ce;0x79aed0;0x90b9d3;0xa7c3d6;0xbeced9] false
let model    = GeometryModel3D()
model.Geometry     <- mesh
model.Material     <- DiffuseMaterial(gradient)
model.BackMaterial <- model.Material

2. Define the scene

In order to see the surface, it is necessary to add some lighting to the scene. To apply a uniform light to all faces an AmbientLight can be used. The model and the light are bound together in a single Model3DGroup (because we don't need to move or transform them separately) and then wrapped in a ModelVisual3D for display.

let light = AmbientLight()
light.Color <- Colors.White

let group = Model3DGroup()
group.Children.Add( model )
group.Children.Add( light )

let visual = ModelVisual3D()
visual.Content <- group

3. Create the user interface

The final steps are to create a Viewport3D, add the ModelVisual3D to it, add a camera and place it in a Window to be displayed. Wrapping the Viewport3D in an instance of the 3DTools TrackballDecorator is all that is required to allow the user to rotate the camera around the origin:
// Create a camera through which to view the model
let camera = PerspectiveCamera()
camera.FieldOfView   <- 60.0
camera.Position      <- Point3D(-1., 1., 1.5)
camera.LookDirection <- Vector3D(1., -1. -1.5)

// Create a viewport to display the visual
let viewport = Viewport3D()
viewport.Camera <- camera
viewport.Children.Add( visual )

let trackball = TrackballDecorator()
trackball.Content <- viewport

let window = Window()
window.Width   <- 800.0
window.Height  <- 800.0
window.Title   <- "Surface plot"
window.Content <- trackball
window.Show()

Application().Run() |> ignore

The results

You can download the code here and do with it as you wish. If you make any enhancements it would be good if you could let me know. Here are a couple of examples of the output:
open System
open Psi.Charting
open System.Windows

open Utils

[<EntryPoint>]
[<STAThread>]
let main args = 
    // Display two windows - both plotting the same function but over
    // different ranges and using different colour schemes.
    let f     = (fun x y -> Math.Cos(x) + Math.Cos(y))
    let p1 = plot_surface (linspace -1.0 1.0 40) (linspace -1.0 1.0 40) f  
    let p2 = plot_surface (linspace -4.5 4.5 100) (linspace -4.5 4.5 100) f
    p2.SetColourScheme(AlternativeColourScheme, true)

    Application().Run() |> ignore
    0

Plot 1: Showing cos(x)+cos(y) over the range -1..1 on both axes

Plot 2: Showing cos(x)+cos(y) over the range -4.5 to 4.5 on both axes

No comments:

Post a Comment