Chapter 6. Additional Application Programming Topics

Table of Contents

Cluster Application Programming
Shared Input Data
Application-Specific Shared Data
General Cluster Programming Issues
Troubleshooting Cluster Problems
Adding Audio
Using Sonix Directly
Using the VR Juggler Sound Manager

We now present topics that will be of interest to VR Juggler programmers in general but are not as low-level as those topics described in Chapter 4, Application Authoring Basics. Furthermore, understanding the use of graphics APIs within a VR Juggler application will help with understanding how these additional features can be used effectively. As such, it is expected that readers of this chapter will have already read and understood the topics presented in the previous chapters of this part of the book.

Cluster Application Programming

Traditionally, multi-screen immersive systems have relied upon dedicated high-end shared memory graphics workstations or supercomputers to generate interactive virtual environments. These multi-screen immersive systems typically require one or two video outputs for each screen and simultaneously utilize several interaction devices. In recent years this trend of almost exclusively using high-end systems has started to change as commodity hardware has become a viable alternative to high-end systems.

Current technologies have empowered PC-based systems with high-quality graphics hardware, significant amount of memory and computing power, as well as support for many external devices. Their application to virtual reality applications is motivated by the dramatic cost decrease they represent and by the wide range of options and availability. To drive a multi-screen immersive environment we need multiple commodity systems working as a single unit, that is, a tightly synchronized cluster. The challenge is that, although the base technology is standard off-the-shelf technology, there is a lack of software for weaving together the cluster into a platform that supports the creation of virtual environments. Furthermore, there is an even greater lack of software that can allow existing virtual environment designed for high-end system to transparently migrate to a cluster.

In this section, we review the clustering capabilities of VR Juggler 2.0. The current implementation of clustering in VR Juggler is the result of the hard work of many people and of several design and implementation iterations. It is the most important new feature in VR Juggler 2.0, and it is also the most complex new feature internally. At the level of the application object, the clustering infrastructure is largely hidden. Those pieces that are exposed have been designed to be easy to use and to work in non-cluster configurations. This aids in application portability between VR system configurations.

Shared Input Data

One approach to implementing clustering for interactive graphics applications is to share all data received from input devices. This is based on an assumption that the interaction with the computer graphics will occur through input devices such as 6DOF trackers, pointing devices, etc. Using this approach, a distinct copy of the VR application is run on each cluster node, but they all see the same input data. Since changes to the scene are based on information from the input devices, all the nodes will make the same state changes each frame and therefore remain synchronized.

This capability is implemented through the Gadgeteer Remote Input Manager and the RIMPlugin used by the Cluster Manager. The basic goal of these components is to provide a distributed shared memory system for VR input device data. Through shared input data, applications can migrate transparently between shared memory VR systems and PC cluster VR systems.

Shared input data is the easiest VR Juggler clustering feature that can be utilized by application object programmers. In simple cases, nothing about an application will have to change to take advantage of shared input data because the details are hidden within the VR Juggler configuration. The Cluster Manager simply needs to be configured to load the RIMPlugin and the Start Barrier Plug-in (StartBarrierPlugin) to enable applications to take advantage of shared input data.

Application-Specific Shared Data

When input data sharing is not sufficient to enable a VR Juggler application to run on a cluster, the next option is to use application-specific shared data. Using this option, VR application developers can easily exchange any type of data across a cluster of machines. For example, we might have a GUI running on a hand-held device that interacts with the VR application to control it. We cannot expect this GUI to connect to all nodes in the cluster. Instead, the GUI connects to a single node that accepts the commands from the GUI and then relays them to the rest of the cluster nodes using application-specific shared data.

Application-specific shared data is implemented through the generic (templated) container type cluster::UserData<T> and the Application Data Manager plug-in (ApplicationDataManager in the Cluster Manager config element plug-in list). As is typically the case with generic containers, any sort of data can be stored and therefore shared. The only caveat is that the contained type must have the following two methods:

  1. void writeObject(vpr::ObjectWriter* writer);

  2. void readObject(vpr::ObjectReader* reader);

Respectively, these two methods are used for serializing and de-serializing shared data types. The simplest way to achieve this is to create a data structure that is a subclass of the abstract type vpr::SerializableObject and overriding its pure virtual writeObject() and readObject() methods.

Deriving from vpr::SerializableObject is not viable in all cases, however. If the data that must be shared is of a type defined in a third-party library, it cannot be modified to derive from vpr::SerializableObject. In that case, the type vpr::SerializableObjectMixin<T> can be used. The methods vpr::SerializableObjectMixin<T>::writeObject() and vpr::SerializableObjectMixin<T>::readObject() must be specialized for the desired type T.

In either case, the end result is a means to serialize the data to be shared across the cluster, and application object programmers will implement methods named writeObject() and readObject(). When writeObject() is invoked, it is passed a pointer to a vpr::ObjectWriter object. An object writer is a simple wrapper around an expandable block of memory. Each write operation appends some number of bytes to the memory block based on the size of the data written. The type vpr::ObjectWriter provides methods for writing all the basic C++ data types (int, float, bool, char, etc.), though they are named based on the cross-platform type identifiers provided by the VR Juggler Portable Runtime (vpr::Int32, vpr::Uint8, etc.). Byte ordering (endian) issues are handled internally by the object writer. The implementation of writeObject() for any shared data type simply copies the data members of the shared data structure into the object writer.

Inversely, the implementation of readObject() reads data from an object reader (an instance of vpr::ObjectReader) into the local copy of the data structure. The object reader contains a fixed-size block of memory and a pointer to the current location in that memory block. Each read operation moves the pointer some number of bytes in the memory block based on the size of the data read.

Important

Due to the symmetric nature of writeObject() and readObject(), the reading and writing of data must occur in the same order. That is, the implementation of writeObject() will write the shared data in some order, and readObject() must read the shared data back out in the same order.

We now present two examples of using the serializable object concept. The first demonstrates the case when a new data structure can be created; the second is the case when a third-party type must be made serializable. When we make a new data structure, it is quite easy to enable serialization. Consider the basic type shown in Example 6.1, “Declaration of a Serializable Type”. It derives from vpr::SerializableObject and overrides writeObject() and readObject() just as it must. It has three data members of different types that define the state of an instance of our type. The serialization and de-serialization implementation, which is quite straightforward, is shown in Example 6.2, “Serializing an Application-Specific Type”.

Example 6.1. Declaration of a Serializable Type

#include <vpr/IO/SerializableObject.h>

class MyType : public vpr::SerializableObject
{
public:
   void writeObject(vpr::ObjectWriter* writer);
   void readObject(vpr::ObjectReader* reader);
   // Other public methods ...

private:
   unsigned int mIntData;
   char         mByteData;
   float        mFloatData;
};

Example 6.2. Serializing an Application-Specific Type

void MyType::writeObject(vpr::ObjectWriter* writer)
{
   writer->writeUint32(mIntData);
   writer->writeInt8(mByteData);
   writer->writeFloat(mFloatData);
}

void MyType::readObject(vpr::ObjectReader* reader)
{
   mIntData   = reader->readUint32();
   mByteData  = reader->readInt8();
   mFloatData = reader->readFloat();
}

Now we consider the case when we need to serialize a third-party type. First, let us assume that we have a type, called SomeType, defined in a header file from a third-party C++ library. This is shown in Example 6.3, “Sample Third-Party Type”. The type has three accessor methods for reading its data and three for writing. We can then specialize the methods of vpr::SerializableObjectMixin<T> as shown in Example 6.4, “Serializing a Third-Party Type Using vpr::SerializableObjectMixin<T>.

Example 6.3. Sample Third-Party Type

class SomeType
{
public:
   unsigned int getIntData();
   void setIntData(unsigned int v);

   char getByteData();
   void setByteData(char v);

   float getFloatDat();
   void setFloatData(float v);

private:
   // Private data ...
};

Example 6.4. Serializing a Third-Party Type Using vpr::SerializableObjectMixin<T>

#include <vpr/IO/SerializableObject.h>

template<>
void vpr::SerializableObjectMixin<SomeType>::
   writeObject(vpr::ObjectWriter* writer)
{
   writer->writeUint32(getIntData());
   writer->writeInt8(getByteData());
   write->writeFloat(getFloatData());
}

template<>
void vpr::SerializableObjectMixin<SomeType>::
   readObject(vpr::ObjectReader* reader)
{
   setIntData(reader->readUint32());
   setByteData(reader->readInt8());
   setFloatData(reader->readFloat());
}

The magic of vpr::SerializableObjectMixin<T> allows the specialized methods to behave as member functions in SomeType. This means that the specialized members have easy access to all public and protected members of SomeType.

Now that we have data serialization out of the way, we can turn our attention to the use of cluster::UserData<T>, the special type that automates application-specific data sharing. For each type of shared data, the application object will have at least one instance of cluster::UserData<T>. Example instantiations of cluster::UserData<T> are shown in Example 6.5, “Declaring Instances of cluster::UserData<T>.

Example 6.5. Declaring Instances of cluster::UserData<T>

#include <vrj/Draw/OGL/GlApp.h>
#include <plugins/ApplicationDataManager/UserData.h>
#include <SomeType.h>
#include "MyType.h"

class AppObject : public vrj::GlApp
{
public:
   void init();
   void preFrame();
   void latePreFrame();
   void draw();

   // Other public member functions ...

private:
   cluster::UserData<MyType> mMyTypeObj;
   cluster::UserData< vpr::SerializableObjectMixin<SomeType> > mSomeTypeObj;
};

Next, we must initialize the cluster::UserData<T> instances so that the Application Data Manager plug-in can identify the shared data types and so that the application can determine which cluster node will be allowed to write to the shared data. While there are two ways to do this, we will show only the recommended approach here. First, a globally unique identifier (GUID) must be defined for each and every shared data type instance. The command-line utility uuidgen is available on most operating systems for generating new GUIDs (also known as universally unique identifiers or UUIDs). These will be used in the application object init() method, as shown in Example 6.6, “Initializing Application-Specific Shared Data”.

Example 6.6. Initializing Application-Specific Shared Data

void AppObject::init()
{
   vpr::GUID mytype_guid("99CFD306-32AB-11D9-A963-000D933B5E6A");
   mMyTypeObj.init(mytype_guid);

   vpr::GUID sometype_guid("A154B8E8-32AB-11D9-B4C9-000D933B5E6A");
   mSomeTypeObj.init(sometype_guid);
}

Important

Do not use the member function vpr::GUID::generate() to initialize the type-specific GUID objects. This will result in every cluster node always having a different GUID value every time the application is run (because GUIDs are unique by definition). If this happens, the Application Data Manager plug-in will never be able to complete its initialization, and the application frame loop will not be able to start on all the cluster nodes.

In conjunction with this, two config elements need to be created. These will be used by the Application Data Manager plug-in to identify which cluster node will be the writer node. The “guid” properties must match the string values used to initialize the vpr::GUID objects in Example 6.6, “Initializing Application-Specific Shared Data”. The “hostname” properties set the name of the cluster node that will be the shared data writer. An example of this is shown in Example 6.7, “Application-Specific Shared Data Configuration”.

Example 6.7. Application-Specific Shared Data Configuration

<?xml version="1.0" encoding="UTF-8"?>
<?org-vrjuggler-jccl-settings configuration.version="3.0"?>
<configuration
   xmlns="http://www.vrjuggler.org/jccl/xsd/3.0/configuration"
   name="Example Shared Application Data Configuration"
   xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
   xsi:schemaLocation="http://www.vrjuggler.org/jccl/xsd/3.0/configuration http://www.vrjuggler.org/jccl/xsd/3.0/configuration.xsd">

   <elements>
      <application_data name="MyType Shared Data" version="1">
         <guid>99CFD306-32AB-11D9-A963-000D933B5E6A</guid>
         <hostname>machine1</hostname>
      </application_data>
      <application_data name="SomeType Shared Data" version="1">
         <guid>A154B8E8-32AB-11D9-B4C9-000D933B5E6A</guid>
         <hostname>machine1</hostname>
      </application_data>
   </elements>
</configuration>

Caution

Be very careful to ensure that the GUID strings match correctly. This means matching the strings in the application_data config element “guid” property with the use in the application code. If the GUID strings are not matched correctly, the Application Data Manager will not be able to match the objects initialized in the application object init() method.

Now that the shared data is initialized and ready to use, we can write to and read from it—the Application Data Manager plug-in will take care of the rest. Only one node can be allowed to write to the data. This is determined through the use of the method cluster::UserData<T>::isLocal(). This method returns a Boolean value that indicates whether the data is “local.” The local node is the one named in the configuration element, as shown earlier. Writes to shared data should only occur in preFrame() or postFrame() after testing the result of cluster::UserData<T>::isLocal(). This is shown in Example 6.8, “Writing to Application-Specific Shared Data”.

Important

The cluster::UserData<T> instances introduce a level of indirection (using the Smart Pointer design pattern) for accessing the shared data that works in both the cluster and the non-cluster case. No direct access to shared data is allowed when using cluster::UserData<T>. This is true both for reading and for writing, demonstrated in Example 6.8, “Writing to Application-Specific Shared Data”, in Example 6.9, “Reading from Application-Specific Shared Data in latePreFrame(), and in Example 6.10, “Reading from Application-Specific Shared Data in draw().

Example 6.8. Writing to Application-Specific Shared Data

void AppObject::preFrame()
{
   if ( mMyTypeObj.isLocal() )
   {
      // Computations ...
      mMyTypeObj->setIntData(...);
      mMyTypeObj->setByteData(...);
      mMyTypeObj->setFloatData(...);
   }

   if ( mSomeTypeObj.isLocal() )
   {
      // Computations ...
      mSomeTypeObj->setIntData(...);
      mSomeTypeObj->setByteData(...);
      mSomeTypeObj->setFloatData(...);
   }
}

After the snapshot of the application-specific shared data for the current frame has been distributed to the cluster nodes, it is time to read the shared data and set up the application state for rendering the current frame. This should be done in the application object method latePreFrame() or in draw(). This is demonstrated in Example 6.9, “Reading from Application-Specific Shared Data in latePreFrame() and in Example 6.10, “Reading from Application-Specific Shared Data in draw(). All the nodes in the cluster will read the results of the computations made in preFrame() and set up the application state before rendering. In general, there should be no need to use cluster::UserData<T>::isLocal() at this point.

Example 6.9. Reading from Application-Specific Shared Data in latePreFrame()

void AppObject::latePreFrame()
{
   mStateVar1 = mMyTypeObj->getIntData();
   mStateVar2 = mMyTypeObj->getByteData();
   // And so on ...
}

Example 6.10. Reading from Application-Specific Shared Data in draw()

void AppObject::draw()
{
   int  state_var1 = mMyTypeObj->getIntData();
   char state_var2 = mMyTypeObj->getByteData();
   // And so on ...
   // Render the scene ...
}

The choice of which method to use depends on the application type and on the data flow of the application object. Scene graph-based application objects will not have a draw() method, so latePreFrame() must be used. For application object types not based on a scene graph (currently only vrj::GlApp), there is a trade off to consider. If latePreFrame() is used, then the rendering state information must be stored in member variables of the application class. If draw() is used, then the state can be defined using stack variables within the method, but the additional function call overhead and pointer derference (resulting from the use of the Smart Pointer pattern) could impact the application frame rate. Remember that draw() is invoked for every window and every viewport within each window. The number of calls to draw() increases further when stereoscopic rendering is enabled. If latePreFrame() is used instead, the Smart Pointer overhead will only be exhibited once per frame.

General Cluster Programming Issues

With the tools for VR Juggler cluster programming in hand, we can turn our attention to specific, higher level areas that must be handled carefully when writing applications that may run on a graphics cluster.

Time-Based Computations

Using time as input to algorithms is a very common occurrence in VR applications. On a cluster, however, each node has its own clock, and each node may start its frame loop at a slightly different time than the other cluster nodes. Differences such as these would result in inconsistencies among the time-based computations across the cluster nodes.

These problems can be avoided through a feature of the input data sharing feature of VR Juggler's cluster support. Every time a Gadgeteer device driver takes a sample from the input device, a time stamp is applied to the sample. This time stamp is included with the shared device data and can be accessed through the device interfaces used by the application objects. A time delta since the last frame can then be calculated. Use of this is demonstrated in Example 6.11, “Calculating Frame Deltas Using vpr::Interval.

Example 6.11. Calculating Frame Deltas Using vpr::Interval

static vpr::Interval last_frame; 
vpr::Interval current_frame = mHead->getTimeStamp();
vpr::Interval diff(current_frame - last_frame);
last_frame = current_frame; // You can get the delta in microseconds from 
                            // vpr::Uint64 delta = diff.usecs();

Important

This technique implies that the Remote Input Manager plug-in (RIMPlugin) and the Start Barrier plug-in (StartBarrierPlugin) must be used by the Cluster Manager.

Random Numbers

Random numbers are, by definition, random. When two computers generate a random number, there is a high likelihood that they will generate different numbers. However, the algorithms used to generate random numbers on computers generate pseudo-random numbers. Pseudo-random numbers are generated by algorithms that have a predictable nature. Given a known starting point (called a seed), the sequence of numbers generated can be predicted. If the same algorithm is seeded identically on two separate computers, the two sequences of generated random numbers will be identical. Varying the seed allows the algorithms to generate different random sequences

This is a very important issue for VR application programming in a cluster configuration. When an application object uses random numbers, each application instance across the cluster must generate the same sequence of random numbers. With VR Juggler, there are two options for making this happen. The first is to seed the random number generator algorithm identically on all the cluster nodes. This is an easy solution as long as all the nodes use the same algorithm to generate random numbers. If the seed is hard coded into the application object initialization, the random number sequence will always be the same for every run of the application. While the numbers will still be random, the predictable nature of pseudo-random number generators could become a detriment.

The second option is to use application-specific shared data, as described above in the section called “Application-Specific Shared Data”. In this case, only one node will generate the random numbers, and the Application Data Manager will take care of sharing the most recently generated number(s) with the other nodes. Using this approach allows for better algorithm seeding and thus better random number generation. It also avoids the issue of different computers having different random number generator algorithms.

Troubleshooting Cluster Problems

In this final section, we present some frequently asked questions regarding VR Juggler application programming and clustering.

1.1. Why doesn't swap lock work with OpenGL Performer-based applications?
1.2. Why is my application navigating differently on every screen?
1.3. Why does my application hang at startup on all the nodes?
1.1. Why doesn't swap lock work with OpenGL Performer-based applications?
1.2. Why is my application navigating differently on every screen?
1.3. Why does my application hang at startup on all the nodes?

1.1.

Why doesn't swap lock work with OpenGL Performer-based applications?

OpenGL Performer can make use of multiple processes to separate the App, Cull, and Draw actions. This allows Performer to spread its work out across three processors. Unfortunately, this interferes with cluster synchronization, so Performer multi-processing cannot be used in conjunction with the cluster capabilities in VR Juggler. The VR Juggler Performer Draw Manager is already written to disable multi-processing when the Cluster Manager is active.

1.2.

Why is my application navigating differently on every screen?

All navigation must be based on time stamps returned from input devices. These time stamps, of type vpr::Interval, can be acquired from any device interface that refers to a device being shared across the cluster, and they can be used to compute the time deltas between frames. More details can be found in the section called “Time-Based Computations”.

1.3.

Why does my application hang at startup on all the nodes?

When using the Start Barrier Plug-in, the application object frame loop methods (preFrame(), postFrame(), draw(), getScene(), etc.) will not be invoked until all the cluster nodes are ready to run. The “ready to run” determination is made by waiting for all the cluster nodes to be connected and for all the cluster plug-ins to complete their initialization. If all of the cluster nodes are running and connected, then there is probably a problem with a cluster plug-in.

For example, the Application Data Manager will not initialize correctly on all nodes if the type-specific GUIDs do not match on all nodes. In this case, disabling the Start Barrier Plug-in will result in the data-local cluster node being the only one that starts correctly. All the others will fail to open any display windows. See the section called “Application-Specific Shared Data” for more information on this topic.