Computer Graphics Workshop '97 Lecture Notes | 1/27/97 |
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 new
new. 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 fieldThe 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++.
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.
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++ #includeIf 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 toclass 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); }
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 arrayGlobal 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++ #includeclass 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;
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.
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.
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.
$Id: index.html,v 1.1 1997/01/24 17:50:06 kbrussel Exp $