This is the second in a series of posts intended to fill in the missing pieces for a developer familiar with a 2D graphics API who wants to dabble in 3D with the Avalon platform To be honest, I really was not particularily happy with the first article. I wanted this series to be a very pragmatic introduction to 3D graphics and my last article quickly veered into into the abstract with its discussion about model space vs. world space. I aim to correct that this time around. This time we will be exploring transforms and using them to construct the 3D axis model we saw in the previous article. Along the way we will need to acquaint ourselves with several classes in the System.Windows.Media3D namespace.
Model3D is the abstract base class for any object which participates as a node in the 3D scene graph. From 2D you are probably familiar with building UI by creating a tree of UIElements (or controls, or HWNDs, etc. depending on the terminology used by your favorite platform). Each element probably surfaced properties like Top, Left, Width, and Height, etc. to position and size the child control relative to its parent. Model3D is similar to the element base class from your favorite 2D platform. You create a 3D scene by building a graph of objects which all derive from Model3D. Model3D has a Transform property which can be used to position, size, and orient the Model3D relative to its parent.
The comparison with UIElement is not entirely accurate, however. UIElements are heavier weight objects which support layout, events, are extensible, etc. Model3Ds are light weight sealed graphics primitives.
The Model3DCollection is a concrete subclass of Model3D. The purpose of a Model3DCollection is to group a set of Model3Ds together so they can be transform (moved, rotated, scaled, etc.) together. Continuing the UIElement analogy, a Model3DCollection would be like a container element (or panel, canvas, etc.) You add Model3Ds to a Model3DCollection via the Children property.
Model3DCollection group = new Model3DCollection();
group.Transform = new TranslateTransform3D(...); // Move entire group
We talked about transforms at a high level in the first article. Transform3D is the abstract base class for our 3D transform classes.
Applying a TranslateTransform3D to a Model3D effectively moves all of the points in the Model3D in the direction of the supplied offset vector. For example, an offset vector of <0,1.6,1> would move the point (4,4,4) to (4,5.6,5). (I say effectively because as we discussed last time we are not modifying the value of our point. The point is still (4,4,4) in model space. What we have done is change our local frame of reference so that (4,4,4) in model space is (4,5.6,5) in world space.)
myModel.Transform = new TranslateTransform3D(new Vector3D(0, 1.6, 1));
ScaleTransform3D effectively scales the model by the given scale vector about the given center point. Applying a uniform scale (a scale where the scale vector has the same value for X, Y, and Z) proportionally changes the size of the object. For example, a scale of <0.5,0.5,0.5> would make the object 1/2 of its original size while a scale of <2,2,2> would double the model's size. (A scale of <1,1,1> leaves the object the same size). Applying a non-uniform scale (one where the values of X, Y, Z differ) "stretches" or "squishes" the model. For example, <1,2,1> would leave the model the same width and thickness, but make it twice as tall.
By default, scaling causes vertices to expand or contract about the origin (0,0,0) which may not always be the desired behavior. Consider a line segment with points at (2,2,2) and (4,4,4). Applying a uniform scale of <0.1, 0.1, 0.1> you quite possibly want to resize the line segment "in place". That is, since the original center of the segment was at (3,3,3) you expect the new segment to be (2.9,2.9,2,9) - (3.1,3.1,3.1). In actuality what happens is that each component of the end points is multiplied by 0.1 and you get the segment (0.2, 0.2, 0.2) - (0.4, 0.4, 0.4) which is of the expected length, but the position has translated towards the origin. It is common practice to author models with the center about the origin and translate them into place to avoid this issue, but in cases where this is not convenient the ScaleTransform3D offers a ScaleCenter property which allows you to specify the point in 3-space you would like the scale to expand/contract about. If you choose this point to be the perceived center of your model your model will scale "in place". (In actuality what is happening when you define the ScaleCenter is that model space is scaled and then translated to put the center point back in it's original position in world space.)
myModel.Transform = new ScaleTransform3D(new Vector3D(2, 2, 2));
As we mentioned previously, Avalon 3D is a right handed platform which means that if curl your fingers and point the thumb of your right hand in the direction of the axis your are rotating about the direction of the curl of your fingers is the direction of rotation with a positive value for angle (counter-clockwise). Similar to ScaleTransform3D, RotateTransform3D exposes a Center property which allows you to control the center of rotation:
Unlike translation and positive scale transforms which move the origin and change the scale of the coordinate system but leave the cardinal axes pointing in the original direction, rotation changes the direction of the axes in model space. (Aside: a negative scalar will invert the axis which effectively mirrors the model.)
myModel.Transform = new RotateTransform3D(new Vector3D(0, 1, 0), 45);
Frequently you want to apply more than one transform to a model (e.g. rotate it and then translate it). A Transform3DCollection allows you to do this. Transforms added to the Transform3DCollection's Children property are applied to the model in first to last order. Ordering is important because translating and then rotating does not yield the same result as rotating and then translating:
This assumes rotation about the origin. Remember that using the Center property you could have rotated about the point (1,0,0) in figure 5b which would have then rotated the square "in place" and yielded the same end result as Figure 5a. Because geometry is typically modeled about the origin, first sizing the model (scale), then orientating it (rotate), and then moving it to the desired location (translate) commonly results in the desired outcome. There are scenarios where you do want different orderings (e.g., you want to pivot the translated model as shown in 5b). In my experience I find that it usually is more natural to add more hierarchy to your scene (in this case meaning a parent Model3DCollection which rotates the translated child), but there are exceptions of course.
Transform3DCollection xforms = new Transform3DCollection();
myModel.Transform = xforms;
Building the Axes Model
This time you get the source, at least most of it. Building the scene graph for the axes model has been removed and left as an exercise for the reader. You will find a comment in the OnLoaded handler in Window1.xaml.cs indicating where this should be done. The code contains a cylinder and cone models as well as models of the letters used to label the axes. (All converted with IanG's x-to-xaml converter). Your task is to use Model3DCollections with the various Transform3Ds we have been discussing to construct the scene graph for the axes shown above. (Download the source here.)
Step 1: Building a reusable arrow
The first step will be to build a reusable arrow model from the provided Cone and Cylinder models:
Both models fit approximately into the unit cube (a cube 1.0 units tall, wide, and deep) and are centered about the origin. What you will want to do is apply a non-uniform scale to the cylinder to keep it 1.0 unit tall, but make it "skinnier". You will then want to perform a uniform scale to the cone to make it smaller and translate it to above the cylinder. Finally, you will want to group both of these primitives together into a Model3DCollection to create an arrow Model3D that you can re-use in your scene. This part of the scene graph will look roughly like this:
With some experimentation you should be able to create an arrow which looks approximately like this:
Step 2: Composing arrows into the axes
The next step is to take our newly created arrow model, orientate and translate it into position, and then draw it for each of the cardinal axes. This raises the question how do we get our arrow to draw 3 times. One solution would be to use the Copy() method to clone the arrow so we have three copies which can then be grouped under a single Model3DCollection:
This works, however each of those arrows models contains an identical copy of the same 3D geometry. A lighter weight solution is to take advantage of multiparenting of Model3Ds to draw the same arrow three times, but adding new transforms each time to position it differently.
Using multiparenting when possible conserves memory and is considered a best practice, but either approach is fine for a small model like our axes. Whichever method you choose, the result you are looking for at the end of step 2 is:
Step 3: Adding the axis labels
The finishing touch is to reapply the same techniques to add the letter models to the scene to label the axes. As an added challenge my 3D modeler exported these models such that by default the infinitly thin edge is facing the camera. The models pictured below have been rotated 90 degrees about the Y-axis to make them visible:
You may want to add a rotation to your top Model3DCollection to view the finished result from a few angles to verify that your Z-axis is as expected:
At this point you have now built your first scene graph. Along the way he have learned about grouping Model3Ds with a Model3DCollection and we have covered the 3 basic Transform3Ds and learned about combining them with a Transform3DCollection. Next time we will look at the ViewPort3D and Camera classes which will get you well on your way to building your first 3D application "from scratch".