Computer Graphics Workshop '97 Lecture Notes

1/27/97

Today's topics
Using Open Inventor in C++

Open Inventor is fundamentally a C++ class library, and when you write C++ programs using it there are several issues that need to be dealt with which the Scheme interface handles automatically or differently. However, there are several advantages to using C++, primarily that it runs faster than Scheme and that you can extend Open Inventor in more ways than you can using Scheme.

A C++ programmer has to deal with manual memory allocation and deallocation. The most important rule for Inventor is that nodes must be allocated on the heap (using newnew. You must not (and can not) delete nodes yourself (which means that attempting to allocate a node off the stack will result in a compile-time error). Inventor takes care of deallocation of nodes using the reference counting mechanism (again, nodes are deallocated when their reference counts go to zero).

Scheme swept the issue of pointers to objects under the rug. In C++, you must make sure you pass a pointer to an object where necessary. For example, when connecting two fields together in C++, you need to take the address of the source field:

     SoTransform *xform1 = new SoTransform;
     SoTransform *xform2 = new SoTransform;
     xform2->translation.connectFrom(&xform1->translation);
                                  // ^ address of xform1's
                                  // translation field
The first thing you must do in your Inventor application is initialize the scene database. Usually you will do this using SoXt::init("MyProgram");, which initializes the windowing system as well. (If you don't want to use Inventor's rendering facilities, you can call SoDB::init(); instead.) If you're using the window system, the last call in your main() must be SoXt::mainLoop();. This function does not return. When you call it, all of your callbacks and sensors must already be set up.

// Hello, Cone -- Inventor Mentor example 02.4.Examiner
#include <Inventor/Xt/SoXt.h>
#include <Inventor/Xt/viewers/SoXtExaminerViewer.h>
#include <Inventor/nodes/SoCone.h>
#include <Inventor/nodes/SoDirectionalLight.h>
#include <Inventor/nodes/SoMaterial.h>
#include <Inventor/nodes/SoPerspectiveCamera.h>
#include <Inventor/nodes/SoSeparator.h>

void
main(int , char **argv)
{
   Widget myWindow = SoXt::init(argv[0]);
   if (myWindow == NULL) exit(1);

   SoSeparator *root = new SoSeparator;
   root->ref();
   SoMaterial *myMaterial = new SoMaterial;
   myMaterial->diffuseColor.setValue(1.0, 0.0, 0.0);
   root->addChild(myMaterial);
   root->addChild(new SoCone);

   // Set up viewer:
   SoXtExaminerViewer *myViewer =
            new SoXtExaminerViewer(myWindow);
   myViewer->setSceneGraph(root);
   myViewer->setTitle("Examiner Viewer");
   myViewer->show();

   SoXt::show(myWindow);
   SoXt::mainLoop();
}
When writing C++ source files, you must #include the header for each node/engine/action/etc.

#include <Inventor/Xt/SoXt.h>
#include <Inventor/Xt/viewers/SoXtExaminerViewer.h>
#include <Inventor/nodes/SoSeparator.h>
The callback mechanism is simpler in C++ than in Scheme. When setting up a callback, you must write a function whose signature matches that of the specified callback type (SoSensorCB, SoDraggerCB, etc). You can pass in arbitrary (void *) pointer as user data.

void myIdleCB(void *userData, SoSensor *sensor)
{
  SoTransform *myTransform = (SoTransform *) userData;
  // .. do some work
  sensor->schedule();
}

int main(int argc, char **argv)
{
  Widget myWindow = SoXt::init(argv[0]);
  
  // ...
  SoTransform *xform = new SoTransform; 

  SoIdleSensor *idleSensor = new SoIdleSensor(myIdleCB, xform);
  // xform now gets passed in to myIdleCB as user data.
  // Could also pass in any arbitrary pointer and cast it inside
  // the callback.

  //...
  SoXt::mainLoop();
}
In order to write C++ programs more easily you'll need to obtain or make basic data types like strings, lists and expandable arrays. Fortunately there are a lot of implementations of these crucial data types available (libg++ and the Standard Template Library, for two).

A big advantage of writing Inventor programs in C++ is that it's possible to make Inventor subclasses, like new types of nodes, engines, and actions. (The linkages demonstration, which comes with the Inventor development kit and which is installed in /mit/iap-cgw/StdInventorDemos/linkages, illustrates the power of being able to create new types of engines.) However, creating correct Inventor subclasses which follow the style rules is relatively difficult, because there are several conventions to learn. However, this is the most powerful and elegant use of Inventor. The process of extending Open Inventor using subclasses is described in The Inventor Toolmaker: Extending Open Inventor, Release 2, by Josie Wernecke; Addison-Wesley, 1994, ISBN 0-201-62493-1.

Instead of making Inventor subclasses, we can make classes which contain Inventor nodes, engines, etc. and provide convenience functions to keep them working together. This is what we've been doing all along with our Scheme programs and Scheme "objects", and we can extend this programming style to C++.

A Design Strategy for Rapidly Prototyping Small Open Inventor Applications in Scheme

Scheme design goals

The most important part of this design strategy is to aim for object-orientedness. You should encapsulate all of the pieces (subgraphs) of your scene graph into Scheme objects, and provide convenience routines to manipulate them.

As an example, the torus in the Problem Set 3 solutions had a "setColor" method. Someone asked whether that broke the paradigm of Inventor, which is to make the shapes have very little state by putting the state in other nodes. For example, the coloring option could be eliminated from the torus, allowing it to be set in the regular way, using a Material node.

Yes, this approach does break Inventor's paradigm, and in general, when you're designing a toolkit you want to design for reuse, which means making classes as general as possible. However, when designing a "Small Open Inventor Application", you want to quickly wrap up independent portions of the design (like ships in a game) and provide convenience functions to manipulate them. You do not want to have to deal with fifty different nodes throughout the scene graph which control parameters (color, position, visibility, etc.) of various objects in the scene. Instead, you want to deal with twenty objects, each of which encapsulate a few of the above nodes. This reduces complexity and makes your program manageable as it grows. (The meta-goal is to try to reduce complexity while maintaining generality -- "The Art of Computer Science.")

Each of your objects should contain its own scene graph. Make it the responsibility of the object to add (upon creation) and remove (upon request) its scene graph from the global scene graph. The object must store a pointer to the global scene graph upon creation in order to be able to remove itself later. (In C++, described below, you will remove the object's scene graph from the global one when the object is destroyed.) You can also provide a getGeometry() method which returns a pointer to the object's internal scene graph, in case it needs to be tinkered with by someone on the outside (although you should avoid this as much as possible, because it violates the rules of encapsulation).

Reduce as much as possible the number of global procedures. Try to move functionality into the methods of objects instead, but try not to let individual methods get too long. Split them up into multiple methods and have them call each other.

However, in some cases the best way to implement a function is as a global procedure (like the initialization routine for the game itself). Don't force such procedures into methods. (Note the conflict with the previous rule.) Also reduce the number of global variables as much as possible.

Conversion rules for C++

Once your Scheme program is organized according to the strategy above, it is relatively straightforward to rewrite it in C++.

The internal scene graph becomes an instance variable in C++. Nodes that are created once and not referenced again (e.g., by any other method) can be local to the constructor. Other nodes must be declared as instance variables in the header file and instantiated in the constructor. The constructor should take the global scene graph as argument, store a pointer to it, and add the object's local scene graph to the global one. The destructor should remove the object's local scene graph.

// Ship.h++
#include 

class Ship {
private:
  SoSeparator *root;
  SoSeparator *sceneRoot;

  static SoSeparator *shipGeometry;  // see below
  static SoSeparator *shipShadowGeometry;
};

// Ship.c++
Ship::Ship(SoSeparator *sRoot)
{
  // initialize the static ship and shadow geometry (which is
  // shared among all Ship instances) 
  if (!classInitted)
    Ship::init();

  sceneRoot = sRoot;

  root = new SoSeparator;
  // geomRoot and shadowRoot are not referenced
  // outside the constructor
  SoSeparator *geomRoot = new SoSeparator;
  root->addChild(geomRoot);
  SoSeparator *shadowRoot = new SoSeparator;
  root->addChild(shadowRoot);

  // ...build geometry and shadow scene graphs...

  sceneRoot->addChild(root);

  // ...other initialization code...
}

Ship::~Ship()
{
  sceneRoot->removeChild(root);
}
If you have geometry which is the same for each of a certain type of object (i.e., all your ships look the same), make all of the nodes which need to be accessed (at least the root node) a static member of the class, initialized to NULL. Upon first construction of an object of this type, instantiate the geometry nodes. (For example, see the call to Ship::init(); above. The following code is in the same file:)

SoSeparator *Ship::shipGeometry = NULL;
SoSeparator *Ship::shipShadowGeometry = NULL;

void
Ship::init()
{
  // Set up ship and shadow geometry
  initShipGeometry();    // instantiates and sets up shipGeometry
  initShadowGeometry();  // instantiates and sets up shipShadowGeometry
  classInitted = 1;
}
All of the object's "local variables" become instance variables in C++. All of the "initialization code" for the object (method calls which occur upon creation of the object; for example, the torus tells itself to generate the geometry when it is first created) goes into the object's constructor.

Global variables become static data members of classes. There should be no global variables at all in your C++ program. For example, the Combat game maintains a global list of all the ships in the game. This list becomes a static member of the Ship class in the C++ version. Here it is a private data member, and the accessor function returns a copy of the list.

// Ship.h++
class Ship {
public:
  static ShipArray getShipList();
private:
  static ShipArray globalShipList; // declaration of the array
};

// Ship.c++
ShipArray Ship::globalShipList;  // definition of the array
Global procedures in Scheme become static methods of classes. There should be no procedures outside of classes at all in your C++ program (with the exception of main, of course). Even callback functions should be static members of classes:

// Combat.h++
class Combat {
private:
  static void idleCB(void *, SoSensor *);
  static void syncCB(void *, SoSensor *);
  static void keyPressCB(void *, SoEventCallback *);
};

// Combat.c++
void
Combat::idleCB(void *, SoSensor *sensor)
{
  if (!singlePlayer)  // singlePlayer is a static data member
                      // of the Combat class
    processNetworkInput();

  // ...update pellets...

  ShipArray ships = Ship::getShipList();
  for (i = 0; i <= ships.hbound(); i++)
    {
      ships[i]->updateState();
    }

  // ...update scores...

  radar->updateFromShipList(ships);

  sensor->schedule();
}

void
Combat::syncCB(void *, SoSensor *)
{
  Ship::getLocalShip()->sendSync();
}
If you organize classes among concepts in your program (each character type in a game, the game region itself, the game board object, etc.) you may find that there are some classes of which it doesn't make sense to have more than one (like instances of the "game" class itself). In this case you can make the constructor private (so it isn't possible for a user to make an object of that type) and make all of its methods and instance variables static. (There are other ways of achieving the same effect.)

The GameRegion class in Combat dictates the size of the playable region and where the ground plane is, and provides convenience routines for placing a ship in a random position. It contains all of its information in static member variables, and is never instantiated.

// GameRegion.h++
#include 

class GameRegion {
public:
  // Statics
  static float getGameRegionSize();
  static float getGroundDepth();
  static SbVec3f randomPosition();
  static float randomDirection();

private:
  GameRegion();  // Static class only

  static float gameRegionSize;
  static float groundDepth;

  static void randomize();
  static int initted;
};

// GameRegion.c++
float GameRegion::gameRegionSize = 80.0;
    // square this many units on a side
float GameRegion::groundDepth = 1.0;
int GameRegion::initted = 0;

Discussion of the prototyping strategy

Why is this only being recommended for "small open inventor applications"? It's probably better to strive for generality in large applications. You really want to avoid making a mess when designing your classes, and want to make them as general and as independent as possible. Otherwise, things will get out of hand very quickly. In small applications you can get away with a bit more slack (proliferation of static methods and variables, for example).

A good way to design for generality may be to follow the paradigms of Inventor, and design your application around Inventor subclasses. Make your classes Inventor nodes, store data in fields (creating new field types where necessary), add information to the traversal state, and create new types of actions. An example of this strategy is ALIVE, a creature generation toolkit. In this large (~50,000 lines of code) application, creatures are a new node type. Behavior systems for the creatures are represented as Inventor scene graphs, and the creatures' behaviors are updated by applying a BehaviorUpdateAction to these scene graphs.

The main disadvantage to this design approach is a strong coupling between the graphics library and your application. However, in this case this might not be a disadvantage. Inventor exhibits a lot of patterns common in good object-oriented designs. Using Inventor as a model may help you improve your own programs.

How to write better object-oriented software

The surest way to write better software is by gaining experience writing software. "Expert designers...[do not] solve every problem from first principles. Rather, they reuse solutions that have worked for them in the past. When they find a good solution, they use it again and again. Such experience is part of what makes them experts." (Design Patterns; see below.)

Here at MIT the laboratory on software engineering, 6.170, teaches a methodology encompassing design, implementation, and testing which is applicable to most modern object-oriented computer languages.

There are many books available on programming design and style, including many C++-specific books.

One of the more recent developments in the field of software design is the concept of design patterns. (Design Patterns: Elements of Reusable Object-Oriented Software. by Erich Gamma, et al.; Addison-Wesley, 1995, ISBN 0-201-63361-2.) This is a catalog of high-level solutions to common design problems. Each pattern is intended to solve a general type of problem, like how to access the elements of a container object without exposing the representation of that object (Iterator). Motivating examples and structures are shown, as well as the consequences of applying a particular pattern. The design approach listed above exhibits the Facade pattern, which provides a simple interface to external clients and translates clients' requests into operations on parts of the subsystem.

Opinions

As an example, let's consider the grouping of global procedures into a class with only static methods (see GameRegion, above); this is essentially the same as having lots of global procedures in C. In general it's better to design your programs so your classes can be instantiated as objects, but there are some times when it doesn't make sense to do so. For example, the "game" class may consume some machine resource, so you can only have one instance per machine. (If you used multicasting with loopback turned off, then two games running on the same machine would never get each others' messages.)

Don't agonize too much over decisions like this. If you spend too much time trying to make your program general it will never get written. It's better to get a working implementation done first, observe its flaws, and rewrite it. Hindsight leads to experience, and experience leads to improvements in the programs you will write later.

Good software engineering (and probably good engineering in general) is the art of balancing what is optimal with what is practical.

C++ Definitions

Next lecture

Extension languages

Back to the CGW '97 home page

$Id: index.html,v 1.1 1997/01/24 17:50:06 kbrussel Exp $