Developing Visualization Applications with Cocoa and VTK, Part 2In Part 1 of this article, we installed VTK, created a project in Project Builder, and made some modifications to the VTK classes to integrate VTK into our Cocoa application.
This time around, we will actually create an application which incorporates elements of Cocoa and VTK.
Since my field of scientific expertise is chemical physics, we will develop a simple 3D molecular animation app called "Animoltion." I will begin by assuming you have already followed the steps in Part 1 and have a working VTK installation and a VTK-Cocoa project setup called Animoltion. If you can't be bothered entering all of the code for Part 2 yourself, you can download it here. If you just want to try out Animoltion, you can download the application here.
|
|
You might also want to check out the excellent Mac DevCenter article by Mike Beam, in which he creates a similar animation application with Cocoa using 2D Quartz graphics.
To begin with, open up your Animoltion project, and select New File... from the File menu. Now select Objective-C NSView subclass, press the Next button, give the file the name MoleculeView.mm, and press Finish. (Note the extension. MoleculeView is to be an Objective-C++ file, so it needs to have the .mm extension.) Move the newly created files to the Classes group if necessary.
The MoleculeView class will be the only new code we will need to create Animoltion. It is a subclass of VTKView (which we created in Part 1) responsible for rendering the atoms in our molecule, and animating them. In a full-blown Cocoa-VTK application you would normally have several classes performing these tasks. For example, you would usually have a controller class, which may handle the animation aspects, and just leave the actual rendering to MoleculeView. But here we will roll all of this functionality into the one class, for the sake of brevity.
Now to write some code. In MoleculeView.h, enter the following precompiler directives:
#import <AppKit/AppKit.h>
#import "VTKView.h"
#import "Atom.h"
#define id Id
#include "vtkSphereSource.h"
#include "vtkDataSetMapper.h"
#undef id
#define NUMBER_OF_ATOMS 3
#define NUMBER_OF_BONDS 3
Here we have imported VTKView, as the superclass of MoleculeView, and another header called Atom.h. We haven't written this yet, but it will contain a struct containing attributes for a single atom.
In the next block of code, we import two VTK classes: vtkSphereSource and vtkDataSetMapper. We will explain what these are below, but at this point it is worth noting that we have again used the trick introduced in Part 1 of redefining "id" to "Id".
|
Related Reading Cocoa in a Nutshell |
The last block defines the number of atoms in our system, and the number of bonds. If you are already having flashbacks to your graduation year chemistry classes, try to relax and it will pass. For those who didn't follow chemistry, a bond is simply a connection between two atoms.
Now we make MoleculeView a subclass of VTKView, and add the ivars:
@interface MoleculeView : VTKView {
IBOutlet NSButton *_playStateButton;
BOOL _playing;
vtkSphereSource *_sphereSource;
vtkDataSetMapper *_sphereMapper;
NSTimer *_animationTimer;
Atom _atoms[NUMBER_OF_ATOMS];
float _stiffnessFactor;
}
Here we define an outlet to an NSButton, which will be a play button similar to that on a video recorder. We also have a BOOL that keeps track of whether our animation is currently playing. The vtkSphereSource and vtkDataSetMapper objects are used to render the spheres which will represent atoms in the 3D scene. An NSTimer is included for the purpose of advancing our animation, and we have an array of Atoms, containing the data for each atom. You might think that the last ivar relates to the excitement you experience when thinking about writing your first Cocoa-VTK application. Although this is not the case, I certainly encourage you to hold the thought until the truth is revealed below.
Now for the method declarations:
-(id)initWithFrame:(NSRect)frame;
-(void)dealloc;
-(IBAction)togglePlayState:(id)sender;
-(IBAction)updateStiffness:(id)sender;
-(void)addSphereActorWithRadius:(float)radius red:(float)r
green:(float)g blue:(float)b alpha:(float)a;
-(void)updateAtomPositions;
-(void)updateActors;
-(void)displayNextFrame;
@end
The designated constructor is the initWithFrame: method. Two actions are defined, one for toggling the play state (i.e. stopping and starting the animation), and the other to update the "stiffness," which I will explain further below. Then we have a number of methods we will be introduced to later.
Before we move on to setting up the user interface, let's quickly define the Atom struct. Create a header file using the File menu, and enter the following code:
typedef struct _Atom {
float coords[3];
float velocity[3];
float radius;
} Atom;
// Initialize an Atom
inline Atom MakeAtom(
float c1, float c2, float c3,
float v1, float v2, float v3,
float radius ) {
Atom newAtom;
newAtom.coords[0] = c1;
newAtom.coords[1] = c2;
newAtom.coords[2] = c3;
newAtom.velocity[0] = v1;
newAtom.velocity[1] = v2;
newAtom.velocity[2] = v3;
newAtom.radius = radius;
return newAtom;
}
As you can see, an Atom has a position, as given by Cartesian coordinates; a velocity, which defines the speed and direction of the atom's movement; and a radius. We have included the MakeAtom inline function to make it easier to create new atoms.
|
Enough coding for the time being ... let's build some interface. Double-click the MainMenu.nib file in the Resources group to open it in Interface Builder.
We now need to make Interface Builder aware of the classes we have declared in Project Builder, so drag the following header files from Project Builder onto the MainMenu.nib window in Interface Builder: vtkCocoaGLView.h, VTKView.h, and MoleculeView.h.
Before we make use of any of these classes, let's set up our application's window. Click on the application window, which should be visible in Interface Builder. Select Show Info from the Tools menu, and make sure the Attributes item is selected in the Inspector's pop-up button. Fill in the attributes using the figure below as an example.
|
|
Basically, you should name the window "Animoltion," and ensure that the Close checkbox is not selected. If you like, you can also select the Textured checkbox. This became available in Mac OS X 10.2, and gives the window that brushed-metal look. Apple recommends this look for single-window applications and applications that in some way behave like real-world devices. Since Animoltion is single-window, and behaves a bit like a video recorder, I think it qualifies. Lastly, if you want your window to be the same size as mine, select the Size pop-up item, and fill in a width of 480 and a height of 383.
With the main window prepared, we can begin filling it with controls. Let's start with the MoleculeView, which will contain our 3D scene. From the Cocoa Container Views tab of the Interface Builder palettes window, drag a CustomView onto the Animoltion window. Reposition and resize it so that it looks like the figure of the Animoltion application above.
Now select the new view, and again bring up the Inspector window by choosing Show Info from the Tools menu, if it is not already visible. Select the Custom Class item from the pop-up, and select the MoleculeView class. Now select the Size item from the Inspector's pop-up, and change the Auto-sizing box so that it has springs on the inside and straight lines on the outside, as shown in the figure below. This will mean our MoleculeView will resize if the Animoltion window is resized.
|
|
We still need a play button, and a slider to control our mysterious stiffness attribute. Drag a button from Palettes window onto the Animoltion window. Open the inspector for the button and set the attributes according to the figure below. The button should be a rounded bevel button with Toggle behavior. Enter Play for the title and Stop for the alternative title. Also, in the Size panel of the Inspector, set up auto-sizing so that the button is free to move relative to the top and sides of the Animoltion window, but has a fixed distance to the bottom of the window. You do this by ensuring that there are springs on the top and sides, but straight lines everywhere else.
|
|
Now drag an NSSlider from the Palettes window and position it accordingly. Open the Inspector and fill in a maximum value of 2.0, a minimum value of 0.5, and a current value of 1.0. The slider should have the Continuous attribute, and you can also select Small if you wish. Set the auto-sizing attributes of the slider in the same way as for the Play button.
Before we can say we are finished with Interface Builder we need to connect up our outlets and select our actions. Control-click and drag from the Play button to the MoleculeView, select target in the Inspector window, and select the action togglePlayState:.
|
|
Do the same for the NSSlider, but connect it to the MoleculeView action updateStiffness:. Now control-click and drag from the MoleculeView to the Play button, and select the MoleculeView outlet _playStateButton and press the Connect button. We can now save our changes and return to Project Builder to finish off.
We're on the home straight now. A lot of the implementation of MoleculeView relates to the physics of moving the atoms. This is a little technical, and not really important to our objectives here, so we will only quickly glance over these sections, and instead concentrate on more relevant aspects.
Add the following preprocessor directives to the top of MoleculeView.mm:
#import "MoleculeView.h"
#import "Atom.h"
#define id Id
#include "vtkProperty.h"
#include "vtkActor.h"
#include "vtkPolyData.h"
#undef id
static const float ANIMATION_TIME_STEP = 0.05;
static const float PROPAGATION_TIME_STEP = 0.20;
static const unsigned SPHERE_RESOLUTION = 32;
The included VTK classes will be discussed when we make use of them below. The ANIMATION_TIME_STEP constant is the time in seconds between frames in our animation. PROPAGATION_TIME_STEP is related to how the atoms are propagated in time. This is one of those technical aspects to which we will pay little attention. The SPHERE_RESOLUTION constant is the number of latitudinal and longitudinal lines used to draw the spheres in our view. The higher this number is, the smoother our spheres will look, and the more computational resources required to draw them.
The SPHERE_RESOLUTION constant is used in our designated constructor, initWithFrame:, which is the first method on our list of those to add.
@implementation MoleculeView
-(id)initWithFrame:(NSRect)frame {
if ( self = [super initWithFrame:frame] ) {
_sphereSource = vtkSphereSource::New();
_sphereSource->SetThetaResolution(SPHERE_RESOLUTION);
_sphereSource->SetPhiResolution(SPHERE_RESOLUTION);
_sphereMapper = vtkDataSetMapper::New();
_sphereMapper->SetInput(_sphereSource->GetOutput());
_playing = NO;
}
return self;
}
This constructor creates two VTK class instances. The vtkSphereSource is a data source object that generates a data set of points on the surface of a sphere. The factory method New() is called first, and then setter methods are called to set the resolution in the latitudinal and longitudinal directions. Note how these setters are indented relative to the New() method. You will see this style of initializing objects used often in VTK.
A vtkMapper in VTK is responsible for mapping data into graphics primitives, such as polygons. vtkDataSetMapper is a subclass of vtkMapper that can be used to map the data set produced by vtkSphereSource. The output of the vtkSphereSource is set to be the input of the vtkDataSetMapper, and later we will connect the output of the vtkDataSetMapper to be the input of another object.
This introduces an important VTK design aspect. VTK utilizes a pipeline architecture: Objects are arranged into pipelines, with the output of one object being assigned as the input of another. This is analogous to building pipelines of UNIX commands, such as
cat file.txt | grep "some string" > output_file.txt
The output of one command is "piped" into another command, which is then piped into another command or file, and so forth. In our example, the data produced by the vtkSphereSource will be fed into the vtkDataSetMapper, which in turn will process the data and output graphics primitives to another object, as we will see later.
The dealloc method calls the Delete() method on these two VTK objects. Recall that the Delete() method is analogous to the Cocoa release method. The destructor also invalidates the animation timer, which we are about to create.
-(void)dealloc {
[_animationTimer invalidate];
_sphereSource->Delete();
_sphereMapper->Delete();
[super dealloc];
}
We perform most of our initializations in the awakeFromNib method:
-(void)awakeFromNib {
_animationTimer =
[NSTimer scheduledTimerWithTimeInterval:ANIMATION_TIME_STEP
target:self
selector:@selector(displayNextFrame)
userInfo:nil
repeats:YES];
_stiffnessFactor = 1.0;
// Initialize atom positions, and create spheres to
// represent atoms
_atoms[0] = MakeAtom( 0.0, 0.0, 0.0, 0.3, 0.0, 0.0, 0.5 );
[self addSphereActorWithRadius:_atoms[0].radius red:0.7
green:0.0 blue:0.0 alpha:1.0];
_atoms[1] = MakeAtom( 1.0, 0.0, 0.0, -0.3, 0.0, 0.2, 0.7 );
[self addSphereActorWithRadius:_atoms[1].radius red:0.0
green:0.7 blue:0.0 alpha:1.0];
_atoms[2] = MakeAtom( 0.0, 1.0, 0.0, 0.0, 0.0, -0.2, 0.5 );
[self addSphereActorWithRadius:_atoms[2].radius red:0.0
green:0.0 blue:0.7 alpha:1.0];
[self updateActors];
[self setNeedsDisplay:YES];
}
First, a timer is created which fires repeatedly at regular intervals. When it fires it calls the displayNextFrame method, which renders the next frame in our animation. The stiffness factor gets set next, and then we initialize the three atoms in our molecule. The method addSphereWithRadius:red:green:blue:alpha: is also called three times. This method adds a spherical "actor" to our view. An actor in VTK is basically an object in a 3D scene. Each sphere will represent an atom and will be moved according to the changing state of its corresponding atom. The call to the updateActors method simply sets the positions of the spheres according to the current positions of the atoms, synchronizing model and view.
Our MoleculeView class has a couple of actions, which we have already connected up in Interface Builder, so you should have a good idea of what they do.
-(IBAction)togglePlayState:(id)sender {
_playing = !_playing;
}
-(IBAction)updateStiffness:(id)sender {
_stiffnessFactor = [sender floatValue];
}
The togglePlayState: action sets the _playing ivar, which determines whether the animation is playing or paused. In the updateStiffness: method, the sender, which is the stiffness slider, is queried for the current stiffness factor, and this is stored in _stiffnessFactor.
The addSphereActorWithRadius:red:green:blue:alpha: performs much of the initialization of VTK objects associated with the MoleculeView class. The following code is thus mostly written in C++.
-(void)addSphereActorWithRadius:(float)radius red:(float)r
green:(float)g blue:(float)b alpha:(float)a{
// Setup actors for atoms
vtkActor *sphereActor = vtkActor::New();
sphereActor->SetMapper(_sphereMapper);
sphereActor->GetProperty()->SetColor(r, g, b);
sphereActor->GetProperty()->SetOpacity(a);
sphereActor->GetProperty()->SetInterpolation( VTK_GOURAUD );
sphereActor->SetScale(radius, radius, radius);
[self renderer]->AddActor(sphereActor);
sphereActor->Delete();
}
This code adds a spherical actor to the renderer of our MoleculeView. The renderer is the object which renders the actors on the screen using OpenGL. But before that a vtkActor object is created, and its mapper is set to _sphereMapper, which we initialized in the initWithFrame: constructor above. Various properties of the actor are also set here: the vtkActor has a vtkProperty object--which we get using GetProperty()--that contains the attributes of the actor object.
|
Most of the attributes are self explanatory, except perhaps the interpolation method. The SetInterpolation() method sets the algorithm used for shading the polygons used to build up our 3D sphere in OpenGL. The VTK_GOURAUD constant corresponds to an algorithm that makes our spheres look more round, rather than polyhedral.
Note that Delete() is called on the sphere actor after it has been added to the renderer. This may seem strange, but remember that the Delete() method corresponds to the Obj-C release method. The renderer retains the actor in the AddActor() method, so sphereActor is not de-allocated.
The displayNextFrame method is called by our animation timer and is responsible for moving the atoms, and updating the view.
-(void)displayNextFrame {
[self updateAtomPositions];
[self updateActors];
[self setNeedsDisplay:YES];
}
We will come to the updateAtomPositions method in a minute; it changes the atoms' positions and velocities. The updateActors method locates the spheres displayed on the screen according to the atoms' positions.
-(void)updateActors {
vtkRenderer *renderer = [self renderer];
vtkActor *actor;
vtkActorCollection *coll = renderer->GetActors();
int actorIndex;
for ( actorIndex = 0; actorIndex < coll->GetNumberOfItems();
++actorIndex) {
actor = (vtkActor *)coll->GetItemAsObject(actorIndex);
actor->SetPosition(
_atoms[actorIndex].coords[0],
_atoms[actorIndex].coords[1],
_atoms[actorIndex].coords[2]);
}
}
This code makes use of the vtkActorCollection class, which is a bit like an NSArray especially for vtkActor objects. The GetActors() method returns all the actors, and we then iterate over the actors, setting their positions using the SetPosition() method with the current coordinates of the atoms. The GetItemAsObject() method returns a pointer to the vtkObject object at the index given. vtkObject is the VTK analog of NSObject in Cocoa. Because we know that the returned object is actually a vtkActor, we cast the pointer returned to be a pointer to a vtkActor. (Such casts are common in C++, which has very strict static typing.)
Now let's move on to the really interesting stuff, the physics. Before you suffer any flashbacks to the horror of high-school physics classes, let me set your mind at ease: I won't cover the physics of the atoms' motion in any detail here. If you are so inclined, I am sure you can figure out what is happening from the code, and if not, I'm sure you don't want to hear it. In any case, it is peripheral to the task at hand: you could use any propagation scheme you fancy to move the atoms.
The updateAtomPositions method encapsulates a 17-year-old's worst examination nightmare, updating the atom positions according to the laws of Newton and the assumption of Simple Harmonic Motion.
-(void)updateAtomPositions {
if ( !_playing) return;
float bondEquilibriumLengths[NUMBER_OF_BONDS] =
{ 1.0, 1.5, 2.0};
float bondStiffnesses[NUMBER_OF_BONDS];
bondStiffnesses[0] = 0.5 * _stiffnessFactor;
bondStiffnesses[1] = 1.0 * _stiffnessFactor;
bondStiffnesses[2] = 0.1 * _stiffnessFactor;
int bondConnectivityFirstAtom[NUMBER_OF_BONDS] =
{ 0, 0, 1 };
int bondConnectivitySecondAtom[NUMBER_OF_BONDS] =
{ 1, 2, 2 };
// Calculate the coordinate differences and
// bond lengths for each bond.
float bondCoordDifferences[NUMBER_OF_BONDS][3];
float bondLengths[NUMBER_OF_BONDS] = { 0.0, 0.0, 0.0 };
unsigned coordIndex, bondIndex;
for ( bondIndex = 0; bondIndex < NUMBER_OF_BONDS;
++bondIndex ) {
unsigned firstAtomIndex =
bondConnectivityFirstAtom[bondIndex];
unsigned secondAtomIndex =
bondConnectivitySecondAtom[bondIndex];
for ( coordIndex = 0; coordIndex < 3; ++coordIndex ) {
bondCoordDifferences[bondIndex][coordIndex] =
_atoms[firstAtomIndex].coords[coordIndex] -
_atoms[secondAtomIndex].coords[coordIndex];
bondLengths[bondIndex] +=
pow( bondCoordDifferences[bondIndex][coordIndex],
2 );
}
bondLengths[bondIndex] = sqrt( bondLengths[bondIndex] );
}
// Update coordinates and velocities.
float temp;
for ( bondIndex = 0; bondIndex < NUMBER_OF_BONDS;
++bondIndex ) {
unsigned firstAtomIndex =
bondConnectivityFirstAtom[bondIndex];
unsigned secondAtomIndex =
bondConnectivitySecondAtom[bondIndex];
temp = -PROPAGATION_TIME_STEP *
bondStiffnesses[bondIndex] *
( bondLengths[bondIndex] -
bondEquilibriumLengths[bondIndex] ) /
bondLengths[bondIndex];
for ( coordIndex = 0; coordIndex < 3; ++coordIndex ) {
_atoms[firstAtomIndex].velocity[coordIndex] +=
temp *
bondCoordDifferences[bondIndex][coordIndex];
_atoms[secondAtomIndex].velocity[coordIndex] -=
temp *
bondCoordDifferences[bondIndex][coordIndex];
}
}
unsigned atomIndex;
for ( atomIndex = 0; atomIndex < NUMBER_OF_ATOMS;
++atomIndex ) {
for ( coordIndex = 0; coordIndex < 3;
++coordIndex ) {
_atoms[atomIndex].coords[coordIndex] +=
PROPAGATION_TIME_STEP *
_atoms[atomIndex].velocity[coordIndex];
}
}
}
@end
Note that the first line in this method returns if the _playing flag is set to NO. This is how the animation is paused: if _playing is NO, the atoms simply don't get moved. The rest of the method performs the mathematical gymnastics necessary to integrate the molecule's equations of motion (whatever that means).
Take a quick breather and look back over the code we have written, in particular the VTK-related code. First of all, note how little there actually is. We created a few objects, connected them together into a pipeline, set a few properties, and bingo: a 3D molecule. You may not be that impressed that you can create spheres in this way, because OpenGL also has methods to make this easy, but you may be more impressed if I tell you that you can create just about any 3D visualization with VTK using the same simple approach. The code required to generate a complex isosurface is not much more complicated or extensive than that required to generate a sphere. With 50 lines of code you can do just about anything with VTK, and not have a polygon in sight. Well, maybe in sight, but not in mind.
Now build and run the project, and if all has gone well, you are ready to experience Animoltion.
Animoltion is not exactly rocket science. Just press Play to start the animation and Stop to pause. Pressing Play again will cause the animation to continue from where it was paused.
We can also now finally solve the mystery of the stiffness slider. If you move the slider to the right, you should see the molecule become stiffer, with the atoms vibrating more rapidly but not moving as far. Moving the slider to the left makes the molecule more floppy, with the atoms vibrating less rapidly, and traveling farther.
Apart from the controls we have built into Animoltion, VTK brings several of its own to the table. Clicking on the 3D view rotates the molecule, for example. Control-Shift-click zooms in and out, depending on where you click. Shift-click translates the view sideways. Control-click causes the view to rotate around the line of sight.
Sometimes you will notice that the atoms move outside the view, or come too close to the camera and are chopped off. In such instances you can press "r", to reset the camera location. You might also try pressing "w", which shows the atoms in wireframe; pressing "s" makes them solid again.
In Part 1 I mentioned a few resources at the end which I think are well worth looking up if you are interested in giving VTK a whirl. You can save yourself a lot of time by purchasing the Visualization Toolkit User's Guide from the Kitware site. Also keep an eye out for The Visualization Toolkit: An Object-Oriented Approach to 3-D Graphics, a new edition of which is due out soon. This book is more theoretical than the user's guide, taking a more in-depth look at the algorithms used in VTK and visualization in general. The Kitware site is also a good source of information. The VTK docs, which are hosted there, are indispensable. Lastly, don't forget the horse's mouth: the VTK source code. If you have followed this article to the letter, you already have a copy on your hard disk.
Before signing off I want to send a big thanks to Michael Norton and Yves Starreveld. Apart from igniting my interest in VTK via his Mac DevCenter article, Michael also read the early drafts of this article, and Yves--the guru of VTK on Mac OS X, and the man responsible for the Cocoa port--also offered his proofreading services free-of-charge.
Drew McCormack works at the Free University in Amsterdam, and develops the Cocoa shareware Trade Strategist.
Read more Developing for Mac OS X columns.
Return to the Mac DevCenter.
Copyright © 2009 O'Reilly Media, Inc.