As the title implies, this is meant to get you up and coding VE programs as quickly as possible. If you have worked with the GLUT library you will find the general structure of VE programs familiar although the interface is different in many ways.
The purpose of VE is to take care of the repetitive tasks for our virtual environments, as well as to provide an abstraction so that a program need only be written once to then run in our cave, on the motion platform, in an HMD, on a workstation, etc. A VE program is made up of the following pieces:
An environment file describes what screens are being used, how they are arranged in space, how they are accessed, their geometry and any calibration information. There is typically a single environment file for any installation (cave, trike, motion platform, etc.). This environment file is read at run-time, so this information does not need to be compiled into your program.
A user profile contains configuration information that is specific to the user of the system. Currently the most crucial information is the stereo eye separation. The format is general in that it can contain other user-specific information in the future. It is expected that in the long run, each user will have a single profile. For now, a default is used.
The manifest describes all of the input devices that are potentially available in the system. There is typically one for each installation or in some cases, one for all installations. Listing a device in the manifest makes it possible for a device to be used by a program, but it is up to the program to specify which of the available devices will actually be used. The manifest helps to specify some information once (e.g. what port is a device hooked up to, which host is a server running on, at what speed do we talk to the head tracker, etc.) in one location.
Each program typically has one or more device usage files. These files describe explicitly what input devices to use and how to integrate them into the program. The device usage file allows you to remap incoming events from various input devices so that you need not rebuild your program for each new input device that you want to use.
In addition to the above data there actually needs to be a program to run. The program will be largely concerned with two tasks: rendering the geometry and controlling the logic of the simulation.
The following is a simple annotated example. The program simulates
a large cube with a different colour on each face that is rotating around
the origin. The user can control which axes (x, y, or z) the cube is
rotating around; they can toggle the rotation around any one of the
axes on or off. Initially the cube is rotating around the y axis.
The code here is complete except for many of the comments which have
been replaced with inline text instead. This program can be found in the
VE distribution in the examples/cube
directory.
Any VE program must include the appropriate header files. There is one that is critical:
ve.h
- This is the main VE header file.#include <stdlib.h> #include <ve.h> #include <GL/gl.h> #include <GL/glu.h>
We define some constants for the program. ROTSPEED
is the
speed in degrees/second that we want to turn. FRAME_INTERVAL
is the minimum amount of time that will elapse between animating frames in
milliseconds. The value defined here will turn out to be slightly more
than 60 frames/second.
#define ROTSPEED 45.0 #define FRAME_INTERVAL (1000/60)
The following are the state variables for the program. We're tracking the rotation angle around each access and a flag indicating whether or not we should increment the angle around each access. The variables are grouped together into a single structure called state for convenience. We will need to tell VE later on that these variables need to propagated to any rendering nodes that we use.
struct { float ang[3]; int rot[3]; } state = { { 0.0, 0.0, 0.0 }, { 0, 1, 0 } };
The following are the declarations for the callback functions we will be using. We'll discuss them in greater detail when we define them below.
void setupwin(VeWindow *w); void display(VeWindow *w, long tm, VeWallView *wv); void update_angles(void *); int axis_toggle(VeDeviceEvent *, void *);
int main(int argc, char **argv) {
Every VE program should start with a call to "veInit()". This sets up basic structures and allows the library to parse and process any command-line arguments. After returning from veInit(), any arguments the library recognized will have been removed. Thus, if we so desired, we could check for program-specific options at this point.
veInit(&argc,argv);
If we have specific needs for the framebuffer configuration that we will be using, we need to let the library know at this point by setting options. Option names and values are strings.
veSetOption("depth","1"); /* request a depth buffer (Z-buffer) */ veSetOption("doublebuffer","1"); /* request a double-buffered config */
Other recognized OpenGL/GLX-layer options are:
red
, green
,
blue
, alpha
- requests a specific minimum depth for
red, green, blue, and alpha components. All buffers will have
bits for red, green, and blue, but not necessarily alpha. If your
program needs an alpha component in the frame-buffer you should include
veSetOption("alpha","1");
Note that options can also be set via the command-line, e.g.
prog -ve_opt alpha 1
but in general, requirements of the program should be hardcoded.
We need to setup two rendering callbacks - one for initializing a window when it is created, and another for rendering a window (similar to a GLUT display callback).
veRenderSetupCback(setupwin); veRenderCback(display);
VE supports many different models of rendering. There is local rendering where the actual rendering work takes place in the same process as simulation. In the local case we do not need to worry about how to propagate information since we have it already. VE can also support parallel rendering where the actual rendering takes place in separate processes on the same host as the simulation, and clustered rendering where rendering is split over several hosts. The particular model can be chosen at run-time. Good programming practice means that we cannot assume that our rendering code necessarily executes in the same process as our simulation code. All processes (whether for rendering or simulation) initialize in the same way, so any information that is read in from files or declared statically in variables will be initialized in all processes. However, for information that will change as the program is running we need a method for propagating that method to the other processes.
VE provides a simple one-way shared-memory model for propagating information. The "shared-memory" part means that we designate a block of memory as a state variable which VE then propagates as needed to the rendering nodes. The "one-way" part means that data is copied from the simulation node to the rendering nodes, but not vice versa. However, keep in mind that there may be only one node in the system - i.e. there is only one process which does all simulation and rendering.
The following bit of code will defined our "state" structure as a state variable. After we call this, we can assume in our rendering code that this variable is always up-to-date. We need an identifier in case we have multiple state variables. The actual value of the identifier is not important as long as it is consistent. The identifier, however, must be >= 0.
#define SV_ANGLES 0 veMPAddStateVar(SV_ANGLES,&state,sizeof(state),VE_MP_AUTO);
Now we setup the input event callbacks. In this case, any event with a device name "axis" (regardless of element name) will be passed to the axis_toggle() function.
veDeviceAddCallback(axis_toggle,NULL,"axis");
Now we setup a timer procedure. This is a callback that will be called once after the specified amount of time has elapsed. We can have the function repeatedly every 'n' seconds by registering a new timer procedure in the callback itself (see the code for update_angles() below). In this case, we will call the update_angles() in "0" milliseconds, which means "as soon as possible".
veAddTimerProc(0,update_angles,NULL);
It is generally a good idea to set the Z clip planes at this point. There is only one set of global values. This will likely be improved in a later version of the library.
veSetZClip(0.1,10.0);
Once our initialization is complete, we enter VE's event loop and let the library take over from here. veRun() should never return, but good form indicates we should handle the unexpected as gracefully as possible.
veRun(); return 0; }
The window initialization callback. This is called once for each window. At this point, it is safe to make OpenGL calls. It is generally used to setup the OpenGL context. Many VE programs will use multiple windows and will not share OpenGL contexts. Thus you should be sure to define any lights, materials, textures, etc. in *every* window.
Calls to this callback are serialized - that is, there are no other threads in the user program when this function is called.
void setupwin(VeWindow *w) { glEnable(GL_DEPTH_TEST); glShadeModel(GL_FLAT); }
The following function is provided as a easy means to draw the cube. We will call it from our rendering callback.
void draw_cube(float f) { glBegin(GL_QUADS); glColor3f(1.0,0.0,0.0); /* red */ glVertex3f(-f,-f,-f); glVertex3f(-f,-f,f); glVertex3f(f,-f,f); glVertex3f(f,-f,-f); glColor3f(0.0,1.0,0.0); /* green */ glVertex3f(f,-f,f); glVertex3f(f,-f,-f); glVertex3f(f,f,-f); glVertex3f(f,f,f); glColor3f(0.0,0.0,1.0); /* blue */ glVertex3f(f,f,-f); glVertex3f(f,f,f); glVertex3f(-f,f,f); glVertex3f(-f,f,-f); glColor3f(1.0,1.0,0.0); /* yellow */ glVertex3f(-f,f,f); glVertex3f(-f,f,-f); glVertex3f(-f,-f,-f); glVertex3f(-f,-f,f); glColor3f(0.0,1.0,1.0); /* cyan */ glVertex3f(-f,-f,-f); glVertex3f(f,-f,-f); glVertex3f(f,f,-f); glVertex3f(-f,f,-f); glColor3f(1.0,0.0,1.0); /* magenta */ glVertex3f(-f,-f,f); glVertex3f(f,-f,f); glVertex3f(f,f,f); glVertex3f(-f,f,f); glEnd(); }
The GL rendering callback. This is called once for each window in each frame that is rendered. Note that these callbacks may be called in parallel - that is, it is possible for multiple threads to be executing this code at once. You should not initialize the projection or modelview matrices - VE sets these up for you. You can push/pop/multiply the existing matrices as you normally would, or read the existing matrices to help in culling.
void display(VeWindow *w, long tm, VeWallView *wv) { glClear(GL_DEPTH_BUFFER_BIT|GL_COLOR_BUFFER_BIT); glMatrixMode(GL_VIEWING); glPushMatrix(); /* rotate */ glRotatef(state.ang[0],1.0,0.0,0.0); glRotatef(state.ang[1],0.0,1.0,0.0); glRotatef(state.ang[2],0.0,0.0,1.0); /* draw the cube */ draw_cube(5.0); glPopMatrix(); }
update_angles() is our timer callback, and we effectively use it as
an animation loop. Note that since our state changes in update_angles,
we'll also push data out to our slaves. Note that the VE will only try
to redraw the screen if it has a reason to - either because the
operating system tells VE that the screen needs redrawing, or because
the application tells VE that the screen needs redrawing. It is
important to call vePostRedisplay()
from timer or event
handlers so that VE knows to redraw the screen.
void update_angles(void *v) { static long last_time = -1; long now; float tm; if (last_time < 0) { /* this is our first call - store the current value of the clock, so we can measure the real interval that we are being called on. */ last_time = veClock(); } else { /* update any angles that need updating */ now = veClock(); /* calculate elapsed time */ tm = (now-last_time)*1.0e-3; for (k = 0; k < 3; k++) { if (state.rot[k]) { state.ang[k] += ROTSPEED*tm; if (state.ang[k] > 360.0) state.ang[k] -= 360.0; } } /* did we actually change anything? */ if (state.rot[0] || state.rot[1] || state.rot[2]) { /* changes will be pushed out to the slaves automatically when the frame is redrawn */ /* let the library know that the screen should be redrawn */ vePostRedisplay(); } /* save the current value of the clock */ last_time = now; }
Since update_angles() is called as a timer procedure, it is only called once and then the library (deliberately) forgets about it. If we want to use it as an animation loop, we need to keep requeueing it, with an appropriate delay. The delay is in milliseconds, and is defined using the FRAME_INTERVAL macro (see the top of the code). Increasing the value of FRAME_INTERVAL makes for slower updates. Decreasing the value of FRAME_INTERVAL makes for faster updates. Note that the speed of the animation is bounded by how fast the screen can actually be rendered.
As a matter of good practice, we will add the timer procedure again with the same argument it was called with (v). In this particular program, this value is always NULL, but again we are protecting against future modifications.
veAddTimerProc(FRAME_INTERVAL,update_angles,v); }
We handle incoming device events in the axis_toggle() callback that we setup earlier. We'll use these events to update which angles to rotate around. Note that we do not need to push this data out to the slaves - the processing which determines the new value of the angles is done on the master.
"arg" is an optional argument that is supplied by the program and is not derived from the event (second argument to veDeviceAddCallback). In this case, we are not using it (it will always be NULL).
An event callback should always return 0. Future support may allow control of how further processing is done through return codes.
int axis_toggle(VeDeviceEvent *e, void *arg) { /* ignore everything except for: - switch or keyboard events that are "down" (i.e. state is non-zero) - trigger events */ if (VE_EVENT_TYPE(e) != VE_ELEM_TRIGGER && !(VE_EVENT_TYPE(e) == VE_ELEM_SWITCH && VE_EVENT_SWITCH(e)->state) && !(VE_EVENT_TYPE(e) == VE_ELEM_KEYBOARD && VE_EVENT_KEYBOARD(e)->state)) { return 0; } /* The event indicates that we should do something. Based upon the element name in the event, toggle the various axis flags. */ if (e->elem) { if (strcmp(e->elem,"x") == 0) x_rot = !x_rot; else if (strcmp(e->elem,"y") == 0) y_rot = !y_rot; else if (strcmp(e->elem,"z") == 0) z_rot = !z_rot; } return 0; }
It is generally a good idea to set the VEROOT
environment
variable when compiling or running VE programs. When compiling, it makes
it easier to point the compiler and the include files and libraries, and
when running tells the library where to locate default configuration files
and device drivers.
The current working version of VE is installed in /cs/home/ivy/ve-2.2 on our Linux systems. If you are using csh or tcsh, then you would set the path as:
setenv VEROOT /cs/home/ivy/ve-2.2
In sh or bash, this would be:
VEROOT=/cs/home/ivy/ve-2.2 ; export VEROOT
The rest of these instructions assume that you have set this variable.
Assuming that the above program has been saved in a file called cube.c
compile it as follows:
cc -I$VEROOT/include -o cube cube.c -L$VEROOT/lib -Wl,-rpath,$VEROOT/lib -lve -lGL
Let's look at what's going on in this compile command:
If VE has been setup properly, you should just be able to execute the program:
./cube
With the defaults, you should get a single window showing the cube from the inside rotating around the y axis. If you encounter an error then one or more configuration files may be missing or incorrect.
When you run a VE program without any options, it searches for default configuration files in the following locations:
If you want to use a different file other than the default, you can use either an environment variable or command-line option to set a different file name:
Value | Env Var | Cmd Line Opt |
---|---|---|
environment | VEENV | -ve_env |
profile | VEPROFILE | -ve_profile |
manifest | VEMANIFEST | -ve_manifest |
devices | VEDEVICES | -ve_devices |
If you override any of the default file names, (command-line options take precedence over environment variables) then the new name is used instead. If the new name is not absolute (i.e. it does not begin with "/") then the same locations in $VEROOT are searched. For example, if you specified the name "foo.env" as the environment, the library would search "./foo.env", "$VEROOT/env/foo.env", and "$VEROOT/foo.env".
Input devices can be controlled and mapped at run-time using a scripting language called "BlueScript". More information is given elsewhere in the VE documentation on the scripting language.
The program compiles and runs, but we would like to be able to control it. In the same directory as the "cube.c" file, there is also a file called "devices" which contains a sample set of event mappings for the cube program. If you copied that file to same directory where you ran the program, then input devices were mapped for you already, even if you were not aware.
The specifics of the devices (type, how to connect to them, etc.) are located in the manifest file. The model is that there is typically a single manifest for an installation and then a devices file for each particular program describing what devices to use and how to use them. The following are some examples of how a devices file may be constructed for the cube program.
Every device that you want to the program to use, must be explicitly used in the "devices" file:
# We'll use the "keyboard" device for input. use keyboard
We can also specify override options for a device at the same time that we use it. These are options that are in addition to the options specified in the manifest. Of particular interest at this point is the optional option. If specified, then it indicates that it is okay for this device to be inaccessible. If a device is used but not marked as optional and is not available when the program starts, then the program will fail with an error message. The following example uses the device called "joystick", but notes that it is optional.
use joystick { optional 1 }
Events are identified by names which have two parts - a "device" and an "element" (an "element" is a part of a device). For example, a joystick may be a device with several buttons, each one an element.
For our cube program, meaningful event names are "axis.x", "axis.y", and "axis.z". But these are not the names that the "keyboard" device will generate. We use filters to modify incoming events. Filters are put together in chains guarded by a specification. During processing, an incoming event is compared to the specification at the beginning of each chain. If it matches, then the event is passed through all the filters in the chain.
In the following examples, the event is represented by an object, a reference to which is stored in the variable e. We access the value of this variable using the '$' symbol.
# Apply a filter to the "x" key so that it toggles the x axis. # The built-in filter "rename" allows us to change the name of an event. filter keyboard.x { $e rename axis.x } # Similary for y and z filter keyboard.y { $e rename axis.y } filter keyboard.z { $e rename axis.z }
Note that if we wanted to, we could achieve all of the above with just:
filter keyboard { $e rename axis }
which would rename the "device" part of an events. However,
this would rename all incoming keyboard events to "axis" events,
which would make the following filter useless.
We should also setup some way for use to exit the program. The scripting language includes a command called "exit" which does this for us.
filter keyboard.Escape { exit }
We can change the processing of an event by returning a specific value from the handler.
# Throw away any event from the 'j' key filter keyboard.j { return discard }
We can also generate new events, in this case, using "copy". "copy" is a member function of an event that returns a new event. Note that the new event is not pushed onto the queue until we *explicitly* push it. We do this with the "push" command. This allows you to change the event before pushing it. Pushing an event pushes a *copy* of the event so we only need to create one copy to work with locally to generate multiple events.
The original event is not affected by a copy. A copied event is processed as soon as processing for the current event finishes, so it is possible to enter into an infinite loop with copies (e.g. copy creates an event that, when filtered, also creates an event, etc.). In this case, we'll use it for a key 'a' that will toggle all axes at once.
filter keyboard.a { # create a copy to work with locally set e2 [$e copy] $e2 rename axis.x # push it $e2 push # once pushed, we can change our event # without affecting the one on the queue $e2 rename axis.y $e2 push # Notice that our last step is a rename rather than a copy. # If we don't do this then we should discard this event or # otherwise be prepared for it to be processed as "keyboard.a" $e rename axis.z }
Since pushes push copies of events, we do not need to copy to do the above. Let's do it without a copy and bind that to button 'b'.
filter keyboard.b { $e rename axis.x $e push $e rename axis.y $e push $e rename axis.z # do not push - let it process normally }
Sometimes we want to change the type of an event to match our specs. Changing the "type" field of an event explicitly changes it to a new type. This will perform a "default" conversion which may or may not be what we want. To have more control over conversion we can use built-in BlueScript functionality.
In this case, assuming that the joystick has an "axis0" that runs from -1.0 to 1.0, we'll mimic a switch down event when the value is greater "0.5".
filter joystick.axis0 { if { [$e type] == "valuator" } { # change it to a switch echo "value is [$e value]" if { [$e value] >= 0.5 } { set state 1 } else { set state 0 } $e type switch $e state $state # rename it to something more useful $e rename axis.x } }
A handy built-in filter is "dump" which will print out any event that is passed to it. It is handy for determining what events are really being delivered to your program.
filter *.* { dump }