Computer Graphics Workshop '97 Lecture Notes

1/21/97

Today's topics
Design and implementation of Combat in Scheme

We have discussed several tools which Inventor provides for creating graphics and providing interactivivity, such as sensors, event callbacks, and selection nodes. Using just these tools, it is possible to create several types of 3D applications. Today we will discuss how to put everything together to create a game; in our example, Combat. (Source code for Combat is located in /mit/iap-cgw/src/combat.scm.)

General design principles for Inventor games

When designing a game which uses Inventor, one must decide how the "main loop" of the game will function. There are at least three models which can be used:
  1. Updates occur via TimerSensor. The state of the game is updated every time a TimerSensor goes off. This is best used in games where actions happen infrequently (an example: Tetris (http://cgw96.www.media.mit.edu/courses/cgw96/1-22), where pieces move down once per second).
  2. Updates occur continuously via IdleSensor. This is the approach used in Combat. The game is supposed to run "as fast as possible". This can be achieved by using an IdleSensor to perform the update work, and rescheduling the IdleSensor inside its callback. Note that this approach is probably "less strenuous" on the machine than using a TimerSensor with a very small interval.
  3. Updates occur from user interaction. The state of the game only changes when the user presses a key, uses the mouse, or selects an object on the screen. This approach can be combined with either of the above two to create user interaction under time constraints. A non-game application (like a 3D modeler) might use this approach alone.

Design of Combat

The primary motivation during the design of the game was to make everything an object. The ships and pellets, the radar, the ground plane, and the score text are all objects. This promotes encapsulation and reduces interdependencies among different parts of the program, which makes it easier to add and change features. We communicate with the objects by sending messages to them; for example, pressing the arrow keys causes messages to be sent to the local ship to change its internal key state. Object-orientedness helps maintain levels of abstraction, so that at a high level the game is easy to program.

Another design goal was to make the game easily networkable. The message-passing system made it easy to add a networking layer which handles the sending of messages to local objects based on input received from the network. The network protocol was designed to be as efficient as possible and still provide reasonable synchronization among machines.

Another goal was to use Inventor to provide animation of the pellets and ships with as little user intervention as possible. Engines were used to control the animation of the pellets and to assist in moving the ship. An added benefit of using engines is that the speed of the game is framerate-independent; this improves the synchronization among machines and allows a decrease in the number of packets sent over the network.

Analysis of the implementation

The first step in implementing Combat was designing the ship. We have already seen how the geometry was implemented (see the lecture notes from 1/13); now let's look at the Scheme object which represents each ship.

(define (new-Ship initial-pos initial-dir scene-root . args)
  ;; initial-pos is an SbVec3f
  ;;   indicating the ship's initial translation
  ;; initial-dir is a float from 0 to 2*PI 
  (define root (new-SoSeparator))
  (define geom-root (new-SoSeparator))
  (define shadow-root (new-SoSeparator))
  (addChildren root geom-root shadow-root)

  ;; Geometry
  (define xl (new-SoTranslation))
  (define rot (new-SoRotationXYZ))
  (define drot (new-SoRotationXYZ)) ;; local rotation;
  				    ;; reset in updateState
  (define lxl (new-SoTranslation))  ;; local translation;
				    ;; reset in updateState
  (send (send xl 'translation) 'setValue initial-pos)
  (send (send rot 'axis) 'setValue SoRotationXYZ::Y)
  (send (send rot 'angle) 'setValue initial-dir)
  (send (send drot 'axis) 'setValue SoRotationXYZ::Y)
  (addChildren geom-root xl rot drot lxl *ship-geometry*)
Each ship has an internal scene graph, and multiple translation and rotation nodes are used to position the ship in the world. The reason for this is so engines can be used to automatically calculate the "delta-position" and "delta-rotation" in between frames (and, more fundamentally, because there is no way to perform a getValue on an engine's output). The incremental forward vector is stored in the lxl node; the incremental rotation is stored in the drot node. During each update cycle, the incremental motions are added to the global ones; this causes the ship to move and turn. (Conceptually, you can think of the xl and rot nodes as moving the ship out to its current position in the world and then rotating about that point towards its current orientation, but in fact these transformations are applied in the opposite order.)

Let's see how these incremental movements are computed.

  ;; Make calculator. Use it for both forward motion and turning.
  (define velocity 0.0)
  (define ang-velocity 0.0)
  (define calc (new-SoCalculator))
  (define elt (new-SoElapsedTime))
  (send (send calc '_a) 'connectFrom (send elt 'timeOut))
  (send (send calc '_b) 'setValue velocity)
  (send (send calc '_c) 'setValue ang-velocity)
  (send (send calc 'A) 'setValue *ship-default-forward*)
  (send (send calc 'expression) 'setValue "oA=A*a*b;oa=a*c")
  (send (send lxl 'translation) 'connectFrom (send calc 'oA))
  (send (send drot 'angle) 'connectFrom (send calc '_oa))
  (define sb-rot (new-SbRotation))
  (define tmp-vec (new-SbVec3f))
  (define up-vec (new-SbVec3f 0 1 0))
An ElapsedTime engine is used to count time since the last update cycle, and is fed into a Calculator engine. The Calculator is the most general type of engine; it takes in multiple vector and scalar inputs, computes an expression containing those inputs, and forces the result to its vector or scalar outputs. (There are several Inventor Mentor examples which demonstrate the use of the Calculator engine: see 13.6.Calculator, 14.1.FrolickingWords, 15.2.SliderBox, and 15.4.Customize. Also see /mit/iap-cgw/Ivy.doc, section III, for Scheme-specific information on the Calculator engine.)

In this case the ship's "default forward vector" (which is set to (0, 0, -1)) is scaled by the product of the ship's current velocity and the elapsed time since the last update. This causes the ship to move forward at a constant rate over time when the ship's velocity is constant. In addition, the ship's angular velocity is multiplied by the elapsed time to create the incremental rotation.

The updateState method of the ship, which is called each tick by the global idle sensor, does this addition of the incremental motion into the global, and resets the ElapsedTime engine.

		 ((eq? message 'updateState)
		  (lambda (self)
		    ;; ...other stuff...
		    ;; Add local rotation into global
		    (send (send rot 'angle) 'setValue
			  (+ (send (send rot 'angle) 'getValue)
			     (send (send drot 'angle) 'getValue)))

		    ;; Add local translation into global
		    (send sb-rot 'setValue up-vec
			  (send (send rot 'angle) 'getValue))
		    (send sb-rot 'multVec
			  (send (send lxl 'translation) 'getValue)
			  tmp-vec)
		    (send (send xl 'translation) 'setValue
			  (send (send (send xl 'translation) 'getValue)
				'operator+ tmp-vec))
		    (send (send elt 'reset) 'setValue)
In order for the ship to do anything interesting, we need some way of controlling it. Keyboard input (as opposed to mouse-based input, which is significantly trickier) was chosen as the controlling mechanism. Here is the keyboard callback, and the code for setting up the EventCallback node which installs it (this node is later added to the global scene graph):

(define (keypress-cb user-data event-callback)
  (let ((event (send event-callback 'getEvent)))
    (cond ((or (= 1 (SO_KEY_PRESS_EVENT event LEFT_CONTROL))
	       (= 1 (SO_KEY_PRESS_EVENT event RIGHT_CONTROL)))
	   (send *local-ship* 'fire)
	   (send event-callback 'setHandled))
	  
	  ((= 1 (SO_KEY_PRESS_EVENT event LEFT_ARROW))
	   (send *local-ship* 'setKeyDown 'left)
	   (send event-callback 'setHandled))

	  ((= 1 (SO_KEY_RELEASE_EVENT event LEFT_ARROW))
	   (send *local-ship* 'setKeyUp 'left)
	   (send event-callback 'setHandled))

	  ((= 1 (SO_KEY_PRESS_EVENT event RIGHT_ARROW))
	   (send *local-ship* 'setKeyDown 'right)
	   (send event-callback 'setHandled))

	  ((= 1 (SO_KEY_RELEASE_EVENT event RIGHT_ARROW))
	   (send *local-ship* 'setKeyUp 'right)
	   (send event-callback 'setHandled))

	  ((= 1 (SO_KEY_PRESS_EVENT event UP_ARROW))
	   (send *local-ship* 'setKeyDown 'up)
	   (send event-callback 'setHandled))

	  ((= 1 (SO_KEY_RELEASE_EVENT event UP_ARROW))
	   (send *local-ship* 'setKeyUp 'up)
	   (send event-callback 'setHandled))
	  )))

(define ev-cb (new-SoEventCallback))
(send ev-cb 'addEventCallback
      (SoKeyboardEvent::getClassTypeId)
      (get-scheme-event-callback-cb)
      (void-cast (callback-info keypress-cb)))
Whenever a key is pressed, the setKeyDown method of the "local ship" (the one which the user controls, in either single- or multi-player games) is called. This method takes care of maintaining the ship's internal key state, and sending this state over the network in multi-player mode. In multi-player mode, each machine controls its own ship, but the "drone" ships (which are the representations of the other machines' ships) also have key states, which are updated from the network, and which control what occurs when the drones update their states.

Now let's look at the implementation of the pellets. The urge here was to make these objects totally autonomous, so they could be created upon firing and require almost no programmatic intervention afterwards. An ElapsedTime engine is used to count off time; a Calculator engine scales this time to between 0 and 1 (0 at the beginning, and 1 when the pellet has reached pellets' maximum time to live).

The Calculator's output is fed into an InterpolateVec3f engine. The Interpolate class of engines (Float, Vec2f, Vec3f, Vec4f, and Rotation) take in two MFields of the specified types as inputs, as well as an alpha value between 0 and 1. They linearly interpolate between each of the inputs according to alpha, and force the result to the output output. Here, the InterpolateVec3f engine has as one input the starting location of the pellet, and as the other the computed end position of the pellet (obtained by knowing the pellets' constant velocity, time to live, the firing ship's current velocity, and the firing ship's current direction, specified as a vector).

(define (new-Pellet starting-pos velocity-vector
		    ship-velocity scene-root . from-where)
  ;; starting-pos and velocity-vector are SbVec3fs
  ;; ship-velocity is a float
  ;; scene-root is the root to which this pellet's geometry
  ;; will be added. Automatically removes itself once it
  ;; gets out of range
  (define root (new-SoSeparator))
  (define xf (new-SoTransform))
  (send root 'addChild xf)
  (send root 'addChild *pellet-geometry*)
  (define interp (new-SoInterpolateVec3f))
  (send (send interp 'input0) 'setValue starting-pos)
  (let ((ending-pos (send starting-pos 'operator+
			  (send velocity-vector 'operator*
				(/ (+ *pellet-velocity* ship-velocity)
				   (send velocity-vector 'length))))))
    (send (send interp 'input1) 'setValue ending-pos))
  (define elt (new-SoElapsedTime))
  (define calc (new-SoCalculator))
  (send (send calc '_a) 'connectFrom (send elt 'timeOut))
  (send (send calc '_b) 'setValue *pellet-expire-time*)
  (send (send calc 'expression) 'setValue "oa=a/b")
  (send (send interp 'alpha) 'connectFrom (send calc '_oa))
  (send (send xf 'translation) 'connectFrom (send interp 'output))
  (send scene-root 'addChild root)
There are only two important methods which the Pellet data type supports. One is checkExpired; this method is called each update cycle, and checks alpha to see if it has reached its final value. If so, the pellet is removed from the global "pellet list" and from the scene graph. The other method is collideWithShip, which takes in a ship as argument. It checks the pellet's current position against the ship's, and if they intersect (according to a 2-D bounding box), the pellet tells the ship to blow up. (The getFromLocation method of the pellet indicates which machine the pellet was fired from in network mode.)
		 ((eq? message 'checkExpired)
		  ;; Checks to see whether this pellet is 
		  ;; out of range. 
		  (lambda (self)
		    (if (>= (send (send interp 'alpha) 'getValue) 1.0)
			(begin
			  (send self 'remove)
			  #t)
			#f)))

		 ((eq? message 'collideWithShip)
		  ;; Checks the current ship list for a collision.
		  ;; Tells that ship to blow up if one was found.
		  ;; Very cheesy; 2-D non-rotated squares.
		  (lambda (self ship)
		    (let ((posn (send ship 'getPosition))
			  (my-posn (send (send xf 'translation)
					 'getValue)))
		      (if (and (< (abs (- (-> posn 'operator-brackets 0)
					  (-> my-posn 'operator-brackets 0)))
				  (+ *pellet-geometry-bbox*
				     *ship-geometry-bbox*))
			       (< (abs (- (-> posn 'operator-brackets 2)
					  (-> my-posn 'operator-brackets 2)))
				  (+ *pellet-geometry-bbox*
				     *ship-geometry-bbox*)))
			  (send ship 'blowUp
				(send self 'getFromLocation))))))

		 ((eq? message 'remove)
		  (lambda (self)
		    (send scene-root 'removeChild root)))
Now that we have the ship and pellet data types defined, let's see how they are used in the idle callback. First the pellets are updated; this is a two-step process. In the first part, collision detection is performed; in single-player mode, each pellet is checked against each ship. In network mode, however, each pellet is only checked for collisions against the local ship, since no machine can tell another's ship to blow up in network mode. (That would violate the invariant that each machine maintains its own local ship and merely displays the others.) In the second part, pellets are removed from the global pellet list when they have expired.

After the pellets are updated, the ships' positions are updated by iterating over the ship list and calling the updateState method of each one. Next, the score text is updated by getting the username and score from each ship (this information is also sent out over the network), or by using the local player's name and score. Finally, the radar is updated; it iterates over the ship list, asking each ship for its current position, and puts a point at that position, relative to the local ship.

(define (ship-idle-cb user-data sensor)
  (if (not *single-player-mode*)
      (process-network-input))
  (update-pellets)
  (for-each (lambda (ship)
	      (send ship 'updateState))
	    *ship-list*)
  (if (not *single-player-mode*)
      (begin
	(for-each (lambda (ship)
		    (if (not (eq? ship *local-ship*))
			(send *text-score* 'updatePlayer
			      (send ship 'getUserName)
			      (send ship 'getScore))
			(send *text-score* 'updatePlayer
			      *local-user-name*
			      *local-score*)))
		  *ship-list*)
	(send *score-viewer* 'viewAll)))
  (send *radar* 'updateFromShipList *ship-list*)
  (send sensor 'schedule))
We'll list the Radar's scene graph code and updateFromShipList method here to illustrate how a PointSet could be used for this sort of application.

(define (new-Radar scene-root)
  (define root (new-SoSeparator))
  (define coords (new-SoCoordinate3))
  (define mat (new-SoMaterial))
  (send (send mat 'emissiveColor) 'setValue 0.8 0.8 0.8)
  (define style (new-SoDrawStyle))
  (define xform (new-SoTransform))
  (define pset (new-SoPointSet))
  (addChildren root coords mat style xform pset)
  (send scene-root 'addChild root)
  (define tmp-rot (new-SbRotation))
  (define up-vec (new-SbVec3f 0 1 0))
  (define tmp-vec (new-SbVec3f))

  (let ((self
	 (lambda (message)
	   (cond ((eq? message 'updateFromShipList)
		  (lambda (self ship-list)
		    (let ((index 0)
			  (my-pos (send *local-ship* 'getPosition)))
		      (for-each
		       (lambda (ship)
			 (send tmp-vec 'setValue
			       (send (send ship 'getPosition) 'getValue))
			 (send tmp-vec 'operator-=
			       (send *local-ship* 'getPosition))
			 (send tmp-rot 'setValue up-vec
			       (- 0.0
				  (send *local-ship* 'getDirection)))
			 (send tmp-rot 'multVec tmp-vec tmp-vec)
			 (send (send coords 'point)
			       'set1Value index tmp-vec)
			 (set! index (1+ index)))
		       ship-list))
		    (let ((sl-length (length ship-list)))
		      (send (send coords 'point) 'setNum sl-length)
		      (send (send pset 'numPoints) 'setValue sl-length))))
Finally, let's look at how the camera is set up. The camera follows the local ship around from behind, looking down at an angle. This is implemented by actually using the transform nodes xl and rot from the local ship to position the camera in the scene graph. Here is a diagram of the structure of this part of the global scene graph:


                                      root
                                   (Separator)
                                        |
                             ---------------------
                             |               (...rest of graph...)
                          cam-sep
                   (TransformSeparator)
                             |
   --------------------------------------------------
   |        |           |              |            |
ship-xl ship-rot cam-local-xlate cam-local-rotate camera
We can think of this organization in the following way. First the camera is moved from the world origin out to the position of the ship. Next it is rotated so its orientation matches that of the ship. Then a local translation is applied to move the camera backwards and up from the ship's center; finally, a rotation is applied so the camera looks down on the ship.

Note that we are actually using the same nodes from the local ship's internal scene graph to place the camera. Therefore, whenever the ship updates these nodes, the camera automatically follows it. The camera's local translation and rotation are constant.

Also note that we put a TransformSeparator above the camera. This allows the camera to be placed independently, without affecting the coordinate system of the rest of the scene graph. WARNING: Do not place a Separator above the camera (completely isolating it from the rest of the scene graph). You will cause Inventor to crash.

(define *root* (new-SoSeparator))
(send *root* 'ref)
(define *cam-sep* (new-SoTransformSeparator))
(send *root* 'addChild *cam-sep*)

;; Set up the local ship and camera transform nodes
(define *local-ship* (new-Ship (game-random-position)
			       (game-random-direction) *root*))

(define *cam-local-xlate* (new-SoTranslation))
(send (send *cam-local-xlate* 'translation) 'setValue 0.0 2.0 3.0)
(define *cam-local-rotate* (new-SoRotationXYZ))
(send (send *cam-local-rotate* 'axis) 'setValue SoRotationXYZ::X)
(send (send *cam-local-rotate* 'angle) 'setValue (* -1.0
						    (/ M_PI 7.0)))
(define *ship-xl* (car (send *local-ship* 'getCameraNodes)))
(define *ship-rot* (cadr (send *local-ship* 'getCameraNodes)))
(send *cam-sep* 'addChild *ship-xl*)
(send *cam-sep* 'addChild *ship-rot*)
(send *cam-sep* 'addChild *cam-local-xlate*)
(send *cam-sep* 'addChild *cam-local-rotate*)
(define *camera* (new-SoPerspectiveCamera))
(send *cam-sep* 'addChild *camera*)
Next lecture

Next time we will cover networking in Scheme using SocketMan (a Socket Manager), and see how networking is implemented in Combat.


Back to the CGW '97 home page

$Id: index.html,v 1.3 1997/01/22 23:22:31 kbrussel Exp $