Table of Contents
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.
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.
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.
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:
vpr::ReturnStatus writeObject(vpr::ObjectWriter* writer);
vpr::ReturnStatus 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.
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:
vpr::ReturnStatus writeObject(vpr::ObjectWriter* writer);
vpr::ReturnStatus readObject(vpr::ObjectReader* reader);
// Other public methods ...
private:
unsigned int mIntData;
char mByteData;
float mFloatData;
};Example 6.2. Serializing an Application-Specific Type
vpr::ReturnStatus MyType::writeObject(vpr::ObjectWriter* writer)
{
writer->writeUint32(mIntData);
writer->writeInt8(mByteData);
writer->writeFloat(mFloatData);
return vpr::ReturnStatus();
}
vpr::ReturnStatus MyType::readObject(vpr::ObjectReader* reader)
{
mIntData = reader->readUint32();
mByteData = reader->readInt8();
mFloatData = reader->readFloat();
return vpr::ReturnStatus();
}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<>
vpr::ReturnStatus
vpr::SerializableObjectMixin<SomeType>::
writeObject(vpr::ObjectWriter* writer)
{
writer->writeUint32(getIntData());
writer->writeInt8(getByteData());
write->writeFloat(getFloatData());
return vpr::ReturnStatus();
}
template<>
vpr::ReturnStatus
vpr::SerializableObjectMixin<SomeType>::
readObject(vpr::ObjectReader* reader)
{
setIntData(reader->readUint32());
setByteData(reader->readInt8());
setFloatData(reader->readFloat());
return vpr::ReturnStatus();
}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);
}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>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”.
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.
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.
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();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 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.
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? |
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
| |
| 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
( 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. | |