Introduction
I wanted to learn a bit about 3D graphics using WPF and C# and decided a fun way would be to implement a simple 3D application to draw L-System fractals.
Background
L-System Basics
An L-system (Lindenmayer system) is a character re-writing system first developed to algorithmically describe plant development. Using a few simple rules you can describe a number of fractal patterns, and patterns that closely resemble plants.
A typical L-System has an axiom (starting string) and rules for expanding the string. This program implements the following rules:
[ push
] pop
F Draw forward
f move forward
B draw backwards
b move backwards
+ rotate +z pitch
- rotate -z pitch
} rotate +y yaw
{ rotate -y yaw
> rotate +x roll
< rotate -X roll
For a 2D system only the + and – rotations are implemented. For 3D system you also have to implement rotating in the other two dimensions. You also have to define how far you rotate, and the line lengths.
It is often useful to be able use placeholder characters or redefine the command characters to make setting up the rules simpler. This application also supports command redefinition.
Simple 2D Examples
Axiom: A
Rules: A=BA, B=AB
Iterations
- A
- BA
- ABBA
- BAABABBA
Now, this example doesn’t do anything, but if the rules and axiom consist of the letters in the rules above then you end up with a long string that gives the commands for drawing a shape. For example:
Axiom: F
Rule: F=F+F--F+F
Angle: 60
Iterations: 2
Creates this picture:
Branching patterns can be done using the push and pop commands:
Axiom: F
Angle: 35
Rule: F=FF-[-F+F+F]+[+F-F-F]
3D Example
This next example is a 3D Hilbert cube (3D version of the Hilbert Square).
Axiom:X
Angle: 90
Rule: X=+<XF+<XFX{F+>>XFX-F}>>XFX{F>X{>
Note that X is not a functional letter. Axioms and rules can use non-functional letters as placeholders that make the more complex patterns easier to implement as l-systems.
Using the code
Most examples for 3D WPF code have a lot of XAML source. XAML is an easy way to implement simple 3D scenes, but I wanted to implement my application using C# code. This demo application uses XAML to setup the form that allows the rules and other parameters to be entered, but all the 3D coding is done using C# calls.
Minimum for 3D WPF Scene
To draw a 3D scene under WPF you need the following at a minimum:
- A control to draw the scene on (I use a grid)
- A ViewPort
- A 3D model
- A camera
- A light
The grid is named GridDraw. A camera and light is created and added to the viewport. A simple 3D model is created and added to the viewport, along with a transform so the model can be moved and rotated. The following code is the minimum to display a 3D scene in WPF.
viewport3D = new Viewport3D();
modelVisual3DLight = new ModelVisual3D();
baseModel = new ModelVisual3D();
model3DGroup = new Model3DGroup();
transformGroup = new Transform3DGroup();
camera = new PerspectiveCamera();
camera.Position = new Point3D(0, 0, -32);
camera.LookDirection = new Vector3D(0, 0, 1);
camera.FieldOfView = 60;
camera.NearPlaneDistance = 0.125;
light = new DirectionalLight();
light.Color = Colors.White;
light.Direction = new Vector3D(1, 0, 1);
modelVisual3DLight.Content = light;
geometryBaseBox = Create3DLine(currLocation, nextLocation, c, lineThickness, boxUp);
model3DGroup.Children.Add(geometryBaseBox);
baseModel.Transform = transformGroup;
baseModel.Content = model3DGroup
viewport3D.Camera = camera;
viewport3D.Children.Add(modelVisual3DLight);
viewport3D.Children.Add(baseModel);
GridDraw.Children.Add(viewport3D);
At this point WPF will draw our box on the grid control.
Creating the Command String
Of course, one of the l-system images will have a large number of boxes making up the model which are added in a loop to a Model3DGroup as we read the command string created by processing the axiom and rules. While drawing the command string requires a recursive function to support the push/pop commands, the creation of the command string is purely iterative.
The code to process the axiom and rules is pretty simple:
- Add the axiom to a string
- For each iteration
- For each rule
- Scan the string, if the character matches a rule then replace the character with the rule string.
- If the current character has no rules, just copy it over
- Replace any redefined commands
Drawing the Command String
Once the axiom and rules are defined we have a long character string that is the instructions to draw an image. Now all we have to do is process this string, character by character, following the instructions. In a 2D system this is very straight forward and you can just use simple trigonometry to draw the pattern.
For example if the final string was F+F+F we would:
- Draw a line from the current location forward in the current direction.
- Rotate the current direction
- Draw a line in the new direction
- Rotate
- Draw the last line pointing in the new direction
Rotating in 3D requires tracking the roll, pitch, and yaw of our current direction. Trying to do this using three separate angles and trigonometry causes many problems including gimbal lock. So we use Quaternions to track the current angle. Each time we have to rotate we just multiple our current direction by a Quaternion that has its axis pointing along the axis we want to rotate on and its angle set to the current amount to rotate. The WPF quaternion structure supports these methods, but it lacks a method to give you a vector that points in the direction the quaternion is currently pointing that can be used to know where the next location is, that is, to move along the direction the quaternion is pointing. Under XNA this would just be the Q.Forward. A simple function that uses the WPF Matrix structure solves this issue:
private Vector3D QuatToVect(Quaternion q, double dd, Vector3D f)
{
Matrix3D m = Matrix3D.Identity;
m.Rotate(q);
f=m.Transform(f * dd);
return f;
}
Now we have all pieces required to process the full command string. Here is an excerpt from the code that creates the 3D model from the command string. Only the F + and – commands are included here. The download has the full code.
Variables:
- str : command string
- max : length of string
- currLocation a Vector3D
- nextLocation a Vector3D
- boxUp a Vector3D that points “up” on the current box
- quatRot a Quaternion that is the current direction we are drawing towards
- vPitch a Quaternion with its axis along the Z-axis
- vForward how we move forward
Vector3D vPitch = new Vector3D(0, 0, 1);
Color[] someColors = { Colors.Red, Colors.Blue, Colors.Green };
Vector3D vMove = new Vector3D();
Vector3D vForward = new Vector3D(1, 0, 0);
for (i = index; i < max; i++)
{
c=someColors[i%someColors.Length]; switch (str[i])
{
case 'F': vMove = QuatToVect(quatRot, lineLength, vForward);
nextLocation = currLocation + vMove;
geometryBaseBox = Create3DLine(currLocation, nextLocation, c,
lineThickness, boxUp);
model3DGroup.Children.Add(geometryBaseBox);
currLocation = nextLocation;
break;
case '+': quatRot *= new Quaternion(vPitch, rotAngle);
break;
case '-': quatRot *= new Quaternion(vPitch, -rotAngle);
break;
}
}
Once all the boxes are added we then add the whole group to our model:
baseModel.Transform = transformGroup;
baseModel.Content = model3DGroup;
The above code is just an excerpt from the full function that ‘draws’ the command string. This function is called recursively to handle the push/pop commands required for branching structures. At a high level the full code does the following:
- Get user parameters
- Create the command string
- Setup the 3D WPF environment
- Recursively add 3D boxes to the model, each box is a 3D ‘line’ in the model.
Transforming the Model
Next we want to be able to view the model from various angles and positions. One way to do this is to move the camera around the model. This is a very good method as it doesn’t require adding transforms to the model, but we only use this method to zoom in and out. There are lots of WPF examples for moving the camera on a logical sphere centered on your model. Here is the zooming code:
private void MouseWheel_GridDraw(object sender, MouseWheelEventArgs e)
{
double speed = 100.0;
if (Keyboard.IsKeyDown(Key.LeftShift) || Keyboard.IsKeyDown(Key.RightShift))
speed /= 8.0;
camera.Position = new Point3D(
camera.Position.X,
camera.Position.Y,
camera.Position.Z - e.Delta / speed);
}
All this does is change the Z position of the camera.
To rotate and translate the model we get the users mouse input and add rotation or translation transforms. This simple code just keeps on adding more transforms as the user moves the mouse. This is just a demo of how to transform a model. I wouldn’t use this method in a ‘real’ application. For a ‘real’ application you would want to minimize the number of transforms applied to model by calculating the new overall transformation matrix and only apply that one transform.
Note that transformGroup is the transformGroup for the whole bundle of small boxes added to modelGroup.
private void GridDraw_MouseMove(object sender, MouseEventArgs e)
{
if (currState == state.rotate)
{
double dx, dy;
dx = mX - e.GetPosition(GridDraw).X;
dy = mY - e.GetPosition(GridDraw).Y;
RotateTransform3D rotateT;
if (Keyboard.IsKeyDown(Key.LeftShift) || Keyboard.IsKeyDown(Key.RightShift))
{
dx *= 4.0;
dy *= 4.0;
}
if (Math.Abs(dx) > smallD)
{
rotateT = new RotateTransform3D(new AxisAngleRotation3D(new Vector3D(0, 0, 1), dx));
transformGroup.Children.Add(rotateT); }
if (Math.Abs(dy) > smallD)
{
rotateT = new RotateTransform3D(new AxisAngleRotation3D(new Vector3D(1, 0, 0), dy));
transformGroup.Children.Add(rotateT); }
mX = e.GetPosition(GridDraw).X;
mY = e.GetPosition(GridDraw).Y;
}
else
{
if (currState == state.translate)
{
double ts;
double dx, dy;
ts = translateSensitivity;
if ( Keyboard.IsKeyDown(Key.LeftShift) || Keyboard.IsKeyDown(Key.RightShift))
ts /= 4.0;
dx = (mX - e.GetPosition(GridDraw).X)/ts;
dy = (mY - e.GetPosition(GridDraw).Y)/ts;
TranslateTransform3D translateT;
if (Math.Abs(dx) > smallD)
{
translateT = new TranslateTransform3D(dx, 0, 0);
transformGroup.Children.Add(translateT);
}
if (Math.Abs(dy) > smallD)
{
translateT = new TranslateTransform3D(0, dy, 0);
transformGroup.Children.Add(translateT);
}
mX = e.GetPosition(GridDraw).X;
mY = e.GetPosition(GridDraw).Y;
}
}
}
Saving and Loading
And finally, we want to be able to save and load the parameters for these patterns. XML is the way to save this sort of thing these days (I think I just dated myself) so here are excerpts from the save and load code. The load code loads the whole xml file into memory then plunks the data back into the correct text boxes. The save code saves each parameter in its own xml node, and all the rules in a set.
private void ButtonSave_Click(object sender, sw.RoutedEventArgs e)
{
XmlDocument xml = new XmlDocument();
XmlNode root;
XmlNode node;
root = xml.CreateElement("pattern");
xml.AppendChild(root);
node = xml.CreateElement("axiom");
node.InnerText = TextboxAxiom.Text;
root.AppendChild(node);
string[] sep = { "\r\n" };
string[] lines = TextboxRules.Text.Split(sep, StringSplitOptions.RemoveEmptyEntries);
foreach (string str in lines)
{
node = xml.CreateElement("rule");
node.InnerText = str;
root.AppendChild(node);
}
Microsoft.Win32.SaveFileDialog diag = new Microsoft.Win32.SaveFileDialog();
diag.Filter = "xml files (*.xml)|*.xml|text files (*.txt)|*.txt|all files(*.*)|*.*";
diag.FilterIndex = 0;
Nullable<bool> result=diag.ShowDialog();
if ( result == true )
{
xml.Save(diag.FileName);
}
}
private void ButtonLoad_Click(object sender, sw.RoutedEventArgs e)
{
Microsoft.Win32.OpenFileDialog diag = new Microsoft.Win32.OpenFileDialog();
diag.Filter = "xml files (*.xml)|*.xml|text files (*.txt)|*.txt|all files(*.*)|*.*";
diag.FilterIndex = 0;
Nullable<bool> result=diag.ShowDialog();
if ( result == true)
{
try
{
XmlNode node;
XmlNodeList nodeList;
XmlDocument xml = new XmlDocument();
xml.Load(diag.FileName);
node = xml.SelectSingleNode("/pattern");
if (node == null)
{
sw.MessageBox.Show("Root node: pattern not found. Likely not a pattern file.");
}
else
{
node = xml.SelectSingleNode("pattern/axiom");
if (node != null)
{
TextboxAxiom.Text = node.InnerText;
}
node = xml.SelectSingleNode("pattern/initialAngle");
if (node != null)
{
TextboxInitialAngle.Text = node.InnerText;
}
TextboxRules.Clear();
foreach (XmlNode n in nodeList)
{
TextboxRules.AppendText(n.InnerText + "\r\n");
}
}
}
catch (Exception ex)
{
sw.MessageBox.Show("Error: " + ex.Message);
}
}
}
Final Comments
When you download the code and have a look at it you might be wondering what the “up” vector is when drawing the 3D boxes. Given two 3D points it isn’t obvious which way is ‘up’ when drawing a box. You can get a plane that is perpendicular to the two points easy enough, but that doesn’t help much orienting the box. The up vector keeps track of which way is UP. It starts off hard-coded to be up and then it is rotated each time the direction is rotated to keep it in sync with the drawing.
This is partially based on my 2D article.
History
First version