Hi everybody,
Here is an article describing how to build a simple 3D physics-based game using Havok 2011.3. The article has also been published on Game Coder Magazine Physics some weeks ago. Enjoy!
Physics is a crucial part of almost any modern 3D game, think about how objects move in a physically plausible way in The Elders Scrolls V: Skyrim (Bethesda Softworks) when you cast a spell on a pile of objects, or how buildings can be destroyed using rocket launchers or tanks in Battlefield 3 (EA DICE). Those titles rely on the services of the Havok Physics engine to move those objects in a physical way. While Havok is not the only company providing physics simulation capabilities to game developers, the Havok Physics engine is surely one of the most appreciated in the AAA market.
In this article we'll learn how to build a very simple physics-based game using Havok Physics. The game will be written in C++. A small Visual Studio 2010 project for this game can be downloaded from here, the archive contains all the code we are going to explain in this article.
Physical simulation is just one of the several components needed when building complex 3D games: graphics, user input and sound are also very important topics. This article will focus on Havok Physics usage, but the small game we are about to develop also requires some graphics capabilities and some user input control loop. For space reasons, we are not going to look closely at the code needed to render the 3D scene or establish the game loop in this game, but here is an overview of the components used:
- The freeglut library is used to create the game window, establish the game loop and control user input. Freeglut is an open source alternative to the OpenGL Utility Toolkit (GLUT) with almost identical functionalities, but it also supports more recent versions of the OpenGL standard. Freeglut can be downloaded from http://freeglut.sourceforge.net/.
- A small set of rendering functions have been written to draw the various objects in the scene, those functions also use freeglut services to draw simple primitives, this is all we need for the graphics side of our game and it's not even 150 lines of code. We are not going to look closely at those functions, but we'll learn what they basically do. Have a look at the full source download if you are interested.
- The free, binary-only PC distribution of Havok Physics and Animation is used to access the services of the Havok Physics engine. This package can be downloaded from http://www.havok.com/solutions/students-and-education and contains full documentation, a complex demo framework to explore and understand the different features of Havok Physics and Animation, various tools and of course the precompiled havok libraries needed to build the game. We will use version 2011.3 of the Havok Physics and Animation SDK.
Let's now establish the gameplay rules for our small game.
In this small game the player controls a sphere that can roll on the surface of a plane, this works by applying torques to the ball based on user input. When a torque is applied to the ball, the friction between the ball and the plane will cause the ball to move around on the plane. The plane is not static, it behaves like if it was pinned in its center, so that it can rotate around it. This means that when the ball moves away from the center, the plane will rotate thanks to the weight of the ball. We'll also make sure that the rotation is limited to a maximum angle so that the ball doesn't fall off too easily. The objective of the game is to bring the ball inside two active areas on the plane without falling off.
Figure 1: waiting for user input to begin.
Figure 2: game started - the ball is at the center of the plane and the system is stable.
Figure 3: when the ball moves away from the center, the plane rotates around it's center because of the ball weight.
Figure 4: the player has to bring the ball inside the active areas on the plane to win the game.
Figure 5: when the player hits both areas without falling, the game ends.
Notice that we are relying on concepts such as friction and weight, which are physics concepts. The Havok Physics engine will make sure that everything happens as programmed.
Ok, we are now ready to have a look at the code.
Havok provides an easy way to control the size of the Havok code footprint in memory, this is based on a series of macros that might be defined to exclude or include specific Havok features. The following code listing shows how to do that for our simple game.
/* GAME CODE */ #include <Common/Base/keycode.cxx> // we're not using any product apart from Havok Physics. #undef HK_FEATURE_PRODUCT_AI #undef HK_FEATURE_PRODUCT_ANIMATION #undef HK_FEATURE_PRODUCT_CLOTH #undef HK_FEATURE_PRODUCT_DESTRUCTION #undef HK_FEATURE_PRODUCT_BEHAVIOR // Also we're not using any serialization/versioning so we don't need any of these. #define HK_EXCLUDE_FEATURE_SerializeDeprecatedPre700 #define HK_EXCLUDE_FEATURE_RegisterVersionPatches #define HK_EXCLUDE_FEATURE_MemoryTracker #include <Common/Base/Config/hkProductFeatures.cxx>
hkProductFeatures.cxx is a file that must be always included in one of your game's translation units (.cpp files), several preprocessor macros might be defined prior to including it to exclude specific features. keycode.cxx must be included before hkProductFeatures.cxx and will define macros like HK_FEATURE_PRODUCT_* depending on the valid keycodes that your distribution contains (for the free binary-only distribution, Havok provides valid keycodes for Physics and Animation). Those macros can then be undefined if a specific product is not required, like Animation in our case. After including keycode.cxx, but before including hkProductFeatures.cxx, we might exclude additional features with macros like HK_EXCLUDE_FEATURE_*.
The following snippet shows the global structure holding all game data.
// GameData structure /* This structure represent all the data associated with a game instance, * this includes persistent data for Havok engine operations, * variables used for user input, and game status variables. */ struct GameData { // Havok persistent objects hkpWorld* m_world; hkpRigidBody* m_sphereBody; hkpRigidBody* m_planeBody; #ifdef DEBUG hkVisualDebugger* m_vdb; hkpPhysicsContext* m_physicsContext; #endif // Sphere movement variables hkReal m_forward, m_strife; // variables for WASD movement // Game state enum StateEnum { STARTING = 0, // not started yet RUNNING, // game is running FINISHED // game finished }; // Game state hkEnum<StateEnum, hkUint8> m_status; // Data associated with a specific game state /* STARTING: * nothing * RUNNING: * FINISHED: * binary 00000001 -> activated first phantom * binary 00000010 -> activated second phantom */ hkUint8 m_statusData; // Default constructor GameData() { m_world = NULL; m_sphereBody = NULL; m_planeBody = NULL; #ifdef DEBUG m_vdb = NULL; m_physicsContext = NULL; #endif m_forward = 0.0f; m_strife = 0.0f; m_status = STARTING; m_statusData = 0x00; } // Function to reset everything in the GameData object void reset() { // reset everything m_sphereBody->setPositionAndRotation(hkVector4(0.0f, 0.6f, 0.0f), hkQuaternion::getIdentity()); m_sphereBody->setLinearVelocity(hkVector4(0.0f, 0.0f, 0.0f)); m_sphereBody->setAngularVelocity(hkVector4(0.0f, 0.0f, 0.0f)); m_planeBody->setPositionAndRotation(hkVector4(0.0f, 0.0f, 0.0f), hkQuaternion::getIdentity()); m_planeBody->setLinearVelocity(hkVector4(0.0f, 0.0f, 0.0f)); m_planeBody->setAngularVelocity(hkVector4(0.0f, 0.0f, 0.0f)); m_forward = 0.0f; m_strife = 0.0f; m_statusData = 0x00; } } g_gameData;
This structure represents all data we need to keep track of while running the game. A single instance of this structure (g_gameData) is created to be used by all game functions (this is basically equivalent to having a group of global variables, but with better encapsulation). The first set of objects are pointers to Havok objects and are used to retrieve information about the running simulation (such as object position and rotation). The real members m_forward and m_strife are used to apply player actions on the ball. m_status represents the current game status. We are using a Havok hkEnum as type for m_status, which is basically a C++ enum with specified storage (in this case 8 bits). There are 3 possible game states:
- STARTING: the game is ready, we are waiting for user input to begin.
- RUNNING: the game is running, it will run until the ball falls off or the player wins the game.
- FINISHED: the game is finished, either because the player won or because the ball fell off. The user than has the option to play again (going back to the STARTING state).
m_statusData represents some additional data associated with a specific state, when the game is RUNNING we need to remember which active areas on the plane were hit by the player.
Apart from the structure members, we also have a default constructor to set some initial meaningful values and a reset() function which is used to return to the original situation when the user wants to play another match. The reset function calls some methods on m_sphereBody and m_planeBody (that is the ball and the plane), those are objects of the hkpRigidBody class (part of Havok Physics). The purpose of reset() is clear: it stops the ball and the plane by setting their linear and angular velocities to zero, and resets their positions to some original value.
Notice that m_vdb and m_physicsContext are only present in the structure if the application is compiled in debug mode. The Havok binary-only distribution ships with the Havok Visual Debugger application (VDB), the VDB is a very important tool when programming physics simulation in a game as it shows the view that the physics engine has of the game world. In our case all objects (the sphere and the plane) will be rendered exactly as they are simulated by the physics engine, but in a real game the physics world will usually be way simpler than the graphics world (e.g. when you have a complex geometry with hundreds of thousands of polygons it is usually sufficient to use a simplified version of the shape to obtain fairly good results in term of physical simulation of the geometry). The VDB connects to the game as a client connects to a server in a client-server networked game, but because establishing and mantaining this connection requires resources we only allow that when the game is compiled in debug mode (so that we don't have any extra overhead when releasing the game).
Let's now have a look at how the game is initialized.
// Havok error reporting function static inline void HK_CALL errorReport(const char* msg, void* userContext) { std::cerr << msg << std::endl; } // Initialization function void OnInit() { // Initialize Havok // The frameinfo buffer must NOT be zero if physics is being used // (it is the solver buffer) #ifdef DEBUG hkMemoryRouter* memoryRouter = hkMemoryInitUtil::initChecking( hkMallocAllocator::m_defaultMallocAllocator, hkMemorySystem::FrameInfo(1024*1024) ); #else hkMemoryRouter* memoryRouter = hkMemoryInitUtil::initDefault( hkMallocAllocator::m_defaultMallocAllocator, hkMemorySystem::FrameInfo(1024*1024) ); #endif hkBaseSystem::init( memoryRouter, errorReport ); { // Create the simulation world { hkpWorldCinfo worldInfo; worldInfo.m_gravity.set(0.0f,-9.8f,0.0f); worldInfo.setBroadPhaseWorldSize(10.0f); g_gameData.m_world = new hkpWorld(worldInfo); // This is needed to detect collisions hkpAgentRegisterUtil::registerAllAgents( g_gameData.m_world-> getCollisionDispatcher() ); } #ifdef DEBUG // Connect to the visual debugger { g_gameData.m_physicsContext = new hkpPhysicsContext(); g_gameData.m_physicsContext->addWorld( g_gameData.m_world ); hkpPhysicsContext::registerAllPhysicsProcesses(); hkArray<hkProcessContext*> contexts; contexts.pushBack( g_gameData.m_physicsContext ); g_gameData.m_vdb = new hkVisualDebugger( contexts ); g_gameData.m_vdb->serve(); } #endif // Create a sphere { hkpSphereShape* sphere = new hkpSphereShape(0.07f); // convex radius for spheres is exactly the sphere radius hkpRigidBodyCinfo rigidBodyInfo; rigidBodyInfo.m_shape = sphere; rigidBodyInfo.m_motionType = hkpMotion::MOTION_DYNAMIC; hkpInertiaTensorComputer::setShapeVolumeMassProperties( sphere, 1.0f, rigidBodyInfo); rigidBodyInfo.m_position.set(0.0f, 0.6f, 0.0f); rigidBodyInfo.m_friction = 1.0f; rigidBodyInfo.m_restitution = 0.2f; g_gameData.m_sphereBody = new hkpRigidBody(rigidBodyInfo); sphere->removeReference(); g_gameData.m_world->addEntity(g_gameData.m_sphereBody); } // Create the level (plane) with the two phantom shapes { hkpRigidBodyCinfo rigidBodyInfo; hkArray<hkpShape*> shapeArray; { hkpBoxShape* planeBox = new hkpBoxShape(hkVector4(0.5f, 0.03f, 0.5f)); planeBox->setRadius(0.005f); // adjust the convex radius to the usage shapeArray.pushBack(planeBox); hkpInertiaTensorComputer::setShapeVolumeMassProperties(planeBox, 10.0f, rigidBodyInfo); } { MyPhantomShape1* phantom1 = new MyPhantomShape1(); hkpBoxShape* box1 = new hkpBoxShape(hkVector4(0.1f, 0.08f, 0.1f)); box1->setRadius(0.0f); // no need for convex radius here hkpConvexTranslateShape* transformBox1 = new hkpConvexTranslateShape(box1, hkVector4(-0.4f, 0.11f, 0.4f), hkpShapeContainer::REFERENCE_POLICY_IGNORE); hkpBvShape* activeBox1 = new hkpBvShape(transformBox1, phantom1); phantom1->removeReference(); transformBox1->removeReference(); shapeArray.pushBack(activeBox1); } { MyPhantomShape2* phantom2 = new MyPhantomShape2(); hkpBoxShape* box2 = new hkpBoxShape(hkVector4(0.1f, 0.08f, 0.1f)); box2->setRadius(0.0f); // no need for convex radius here hkpConvexTranslateShape* transformBox2 = new hkpConvexTranslateShape(box2, hkVector4(0.4f, 0.11f, -0.4f), hkpShapeContainer::REFERENCE_POLICY_IGNORE); hkpBvShape* activeBox2 = new hkpBvShape(transformBox2, phantom2); phantom2->removeReference(); transformBox2->removeReference(); shapeArray.pushBack(activeBox2); } hkpListShape* level = new hkpListShape(&shapeArray[0], shapeArray.getSize(), hkpShapeContainer::REFERENCE_POLICY_IGNORE); rigidBodyInfo.m_shape = level; rigidBodyInfo.m_motionType = hkpMotion::MOTION_DYNAMIC; rigidBodyInfo.m_position.set(0.0f, 0.0f, 0.0f); rigidBodyInfo.m_friction = 1.0f; rigidBodyInfo.m_restitution = 0.5f; g_gameData.m_planeBody = new hkpRigidBody(rigidBodyInfo); level->removeReference(); g_gameData.m_world->addEntity(g_gameData.m_planeBody); } // Create constraint on the level { hkpGenericConstraintData* data = new hkpGenericConstraintData(); hkpConstraintConstructionKit kit; kit.begin(data); { kit.setPivotA(hkVector4(0.0f, 0.0f, 0.0f)); kit.setPivotB(hkVector4(0.0f, 0.0f, 0.0f)); kit.constrainAllLinearDof(); kit.setAngularBasisABodyFrame(); kit.setAngularBasisBBodyFrame(); kit.setAngularLimit(0, -ANGLE_LIMIT, ANGLE_LIMIT); // do not limit rotation around Y axis kit.setAngularLimit(2, -ANGLE_LIMIT, ANGLE_LIMIT); } kit.end(); hkpConstraintInstance* constraint = new hkpConstraintInstance (g_gameData.m_planeBody, NULL, data); g_gameData.m_world->addConstraint(constraint); data->removeReference(); constraint->removeReference(); } } // initialize graphics initGraphics(); }
This listing is where the core Havok initialization and configuration takes place.
The first two operations are always the same when using the Havok engine: initialize the memory system (with hkMemoryInitUtil::init*()) and initialize the base system (with hkbaseSystem::init()). Note that initialization of the memory system is different when debugging the application, this is because in debug mode we are using a checking memory system. A checking memory system is less efficient than the default one, but performs some analysis on the memory used and when exiting the program it will highlight any Havok memory leak encountered during execution.
The next step is to create the simulation world, this happens by creating a new hkpWorld object. We do that by also setting a gravity vector to the real world gravity value (9.8 m/s2 towards -Y, and a new size for the collision detection broadphase (which means that we are optimizing collision detection performances to our scene). The broadphase size represents the side of a cube centered at the origin; generally, this cube should be large enough to encompass the whole scene, but not excessively larger than that.
When considering distances and masses in Havok we need to understand that by default the Havok engine works in meters and kilograms. If a different scale is being used, the most common approach is to scale up or down all the values that the game passes to Havok (like the value of the gravity vector). In any case, the unit scale of the engine cannot be changed by a large factor because of several hard-coded numerical tolerances in the engine. This practically means that using feet instead of meters is simple, but millimiters cannot be used.
After creating the physics world, we create the visual debugger object that will serve connections from the VDB application. This is done only when the application is configured in debug mode.
Now we have to actually create the rigid bodies that Havok Physics will simulate, and establish all the simulation parameters for those bodies. A Havok rigid body is an object of class hkpRigidBody, and it has a reference to a shape object which represents the geometry used for physical simulation. In the case of the sphere we create an hkpSphereShape having radius of 7 cm to be used as shape for the sphere rigid body. A rigid body might be configured as a dynamic object by setting m_motionType to be hkpMotion::MOTION_DYNAMIC (which is also the default value). A dynamic object is a fully-simulated, movable rigid body.
When creating a rigid body, we also have to set its mass properties. The mass value describes how the object reacts to translation actions performed on it (e.g. a force applied in its center of mass), but we also need to set the inertia tensor which describes how the object reacts to rotation actions such as torques. Computing the inertia tensor given the mass and shape of an object is very complicated even when the body density is constant, fortunately Havok provides a utility function to do that inside the hkpInertiaTensorComputer class.
Finally, we set some physical material properties for the sphere, a friction coefficient and a restitution value (which represents the elasticity of an object and governs velocities after collisions - it is the "bounciness" of an object).
Also note that we set a starting position for the sphere rigid body, this is slightly above the plane (60 cm) so that the first thing that will happen will be the sphere falling directly on the center of the plane.
When the sphere rigid body is built, it is assigned to the global storage g_gameData.m_sphereBody, and then we call removeReference() on the sphere shape. Shapes, like rigid bodies, are reference counted objects in Havok Physics. This is because shapes can be shared between rigid bodies and therefore when destroying a rigid body we can't always destroy its shape (because it might be used by other rigid bodies). By keeping track of the number of references to a shape, we can safely delete it when the counter drops down to zero (all referencing entities were destroyed). The reference counter is initialized to 1 (that is one user-owned reference) and when the rigid body is constructed it will add a new reference to the sphere shape, so that then the new value of the counter is 2. When closing the application we will destroy the sphere body by calling g_gameData.m_sphereBody->removeReference(), but this will not cause deletion of the sphere shape is the counter doesn't drop to 0. Therefore, after creation of the sphere body, we drop our reference to the sphere shape, the shape will not be destroyed because the sphere rigid body still owns a reference to it.
The last step for the sphere is the addition of the rigid body to the physics world, using the addEntity() function. Which registers the sphere as an entity in the physically simulated world.
Creation of the plane rigid body proceeds similarly, but there are a few important differences. First of all, we are not using a simple shape for the plane, but an hkpListShape, which represents a list of shapes. The reason for doing this is that while the plane itself will be represented by a simple hkpBoxShape, we also have to attach to the plane the two active box-shaped zones.
We start by creating the simple box shape that represents the main body of the moving plane, the only interesting thing to note here is that we are using the setRadius() function to set a specific value for the convex radius of the hkpBoxShape. The convex radius represents an extra "shell" around a convex shape which is actually used for collision detection and represents the effective surface of the object for the Physics engine. This approach is used because the core convex-convex collision detection algorithm is faster when the two shapes are not actually interpenetrating, adding a shell around the shapes makes it less likely that the shapes themselves will interpenetrate. The default values for the radius is 5 cm, which we change to 5 mm because 5 cm is too large for our small scene (and it would result in a visible gap between the objects). We add the new created hkpBoxShape to an array of shapes that we will then use to build the hkpListShape.
There are multiple ways in Havok Physics to build active zones that allow execution of user-provided code when objects enter or leave a volume. In our case we will be using two shapes of type hkpPhantomCallbackShape. This shape represents a "phantom" shape and has no physical effect in the scene apart from triggering events when other shapes interact with it. To build such a shape we have to derive a new class from hkpPhantomCallbackShape (which is abstract) and implement the two callback events (when something enters or leaves the area). Let's assume we derived two classes MyPhantomShape1 and MyPhantomShape2.
An hkpPhantomCallbackShape is usually used as child for an hkpBvShape. An hkpBvShape associates with any shape (in our case a hkpPhantomCallbackShape) a specified bounding volume shape. This practically means that the action specified our hkpPhantomCallbackShapes will be triggered when an object enters or exits their bounding volume. As bounding volumes we are using shapes of type hkpConvexTranslateShape. This is basically a shape that represents a standard convex shape (like an hkpBoxShape) translated by some vector, and is very handy to position child shapes correctly in an hkpListShape. Construction of an hkpConvexTranslateShape requires a convex shape and a translation vector, we are using hkpBoxShapes as convex shapes, and the translation specifies where to position the phantom shapes on the plane. The extra hkpShapeContainer::REFERENCE_POLICY_IGNORE argument simply says that the reference counter for the child box shape should not be incremented (by doing this, we can avoid removing a reference on the hkpBoxShape while still getting proper cleanup).
After having created the plane hkpBoxShape and the two active shapes, we may produce the final hkpListShape which represents the whole game level, this shape is then used to build the plane rigid body in the same way we did for the sphere.
We now have the two bodies in our physical world, the shape and the plane with the active shapes. Both of them are dynamic, which means they are affected by all physical actions such as gravity, collisions, etc... If we leave everything as it is, both the sphere and the plane will fall down because of gravity. We are therefore going to create a constraint on the plane: we want the plane to rotate around its center, but we don't want it to translate at all.
There are various types of constraints in Havok. For our small game we are going to build a generic constraint using the constraint construction kit (hkpConstraintConstructionKit). A constraint always works between two bodies, and when building a generic constraint we have to specify the relative degrees of freedom of the two. We start by specifying the two pivots, which are the two points on the objects that will be constrained by any linear condition. We specify the two pivot as being in the center of their relative objects frames, and then remove all linear degrees of freedom using constraintAllLinearDof(). This means that the two pivots should always be in the exact same position. The next task is setting the angular constraint basis for the two objects, and in our case we set them to the respective body frames. Using then setAngularLimit(), we enforce a limit on the rotation around X and Z (ANGLE_LIMIT represents a 7 degrees angle in radians).
To finally build the constraint, we use the hkpGenericConstraintData obtained using the constraint construction kit to create an hkpConstraintInstance. We pass plane rigid body and HK_NULL (NULL pointer) to the constructor. When the second object is NULL, Havok Physics implicitly assumes that we are referring to the world fixed body, which is exactly what we want here. After having obtained the constraint instance, we just add it to the physics world using addConstraint().
The last operation of the initialization function is initGraphics(), which basically initializes OpenGL to perform rendering, we'll not look at the body of any rendering function in this article, but the full source is available for download from here.
We saw that two concrete classes MyPhantomShape1 and MyPhantomShape2, inheriting from hkpPhantomCallbackShape, are necessary in the initialization function. The following listing shows their full code.
// Phantom shape class used for the first active area class MyPhantomShape1 : public hkpPhantomCallbackShape { public: void phantomEnterEvent(const hkpCollidable* phantomColl, const hkpCollidable* otherColl, const hkpCollisionInput& env) { g_gameData.m_statusData |= 0x01; // first phantom active } void phantomLeaveEvent( const hkpCollidable* phantomColl, const hkpCollidable* otherColl ) {} MyPhantomShape1() : hkpPhantomCallbackShape() {} MyPhantomShape1( hkFinishLoadedObjectFlag flag ) : hkpPhantomCallbackShape(flag) {} }; // Phantom shape class used for the second active area class MyPhantomShape2 : public hkpPhantomCallbackShape { public: void phantomEnterEvent(const hkpCollidable* phantomColl, const hkpCollidable* otherColl, const hkpCollisionInput& env) { g_gameData.m_statusData |= 0x02; // second phantom active } void phantomLeaveEvent( const hkpCollidable* phantomColl, const hkpCollidable* otherColl ) {} MyPhantomShape2() : hkpPhantomCallbackShape() {} MyPhantomShape2( hkFinishLoadedObjectFlag flag ) : hkpPhantomCallbackShape(flag) {} };
The action triggered when an object leaves the active area will do nothing, when an object enters the active area we will set one of the two phantoms as active in the g_gameData structure (depending on which shape was triggered).
Before examining the function that handles rendering of the scene and stepping of the physical simulation, let's have a look at the deinitialization function.
void OnExit() { deinitGraphics(); // delete havok entities { g_gameData.m_planeBody->removeReference(); g_gameData.m_sphereBody->removeReference(); #ifdef DEBUG g_gameData.m_vdb->removeReference(); g_gameData.m_physicsContext->removeReference(); #endif g_gameData.m_world->removeReference(); } hkBaseSystem::quit(); hkMemoryInitUtil::quit(); }
Nothing surprising here, we remove all retained references to Havok objects, and then simply quit the havok memory and base system. The first operation is the deinitialization of the graphics system (because the last operation during initialization was initGraphics()).
The next listing contains the rendering and simulation stepping function.
// Render the whole game scene static void renderScene(bool firstPhantomActive, bool secondPhantomActive) { beginSceneRendering(); drawSphere(g_gameData.m_sphereBody->getPosition()); drawPlane(g_gameData.m_planeBody->getRotation(), firstPhantomActive, secondPhantomActive); endSceneRendering(); } // Frame rendering function void OnFrame() { switch(g_gameData.m_status) { case GameData::STARTING: { renderScene(false, false); drawText("Game Ready - Press SPACE to begin"); } break; case GameData::RUNNING: { g_gameData.m_sphereBody->applyTorque(FRAME_PERIOD, hkVector4(-g_gameData.m_forward, 0.0f, -g_gameData.m_strife)); g_gameData.m_world->stepDeltaTime(FRAME_PERIOD); #ifdef DEBUG g_gameData.m_vdb->step(FRAME_PERIOD); #endif bool firstPhantomActive = g_gameData.m_statusData & 0x01; bool secondPhantomActive = g_gameData.m_statusData & 0x02; renderScene(firstPhantomActive, secondPhantomActive); const hkVector4& spherePos = g_gameData.m_sphereBody->getPosition(); if( (firstPhantomActive && secondPhantomActive) || // won the game (spherePos.getComponent(1) < -4.0f) ) // lost the game { g_gameData.m_status = GameData::FINISHED; } } break; case GameData::FINISHED: { bool firstPhantomActive = g_gameData.m_statusData & 0x01; bool secondPhantomActive = g_gameData.m_statusData & 0x02; renderScene(firstPhantomActive, secondPhantomActive); drawText("Game Over - Press SPACE to continue"); } break; } }
When launched, the game is in status STARTING, this means that every frame we will just call renderScene() and drawText(). renderScene() is a simple function that renders the whole scene using some simple graphics utility function which we are not going to examine, it draws the sphere in the position returned by g_gameData.m_sphereBody->getPosition() and also draws the plane using the orientation returned by g_gameData.m_planeBody->getRotation() (that is it draws the objects in the positions and orientations suggested by the physics engine). When drawing the plane, we also pass in two flags specifying the state of the active areas (this way we are able to use different colors depending on whether the phantoms have already been touched). On startup, the sphere position and plane orientation will be the initial ones, we are also specifying explicitly that the two active zones are both disabled at the beginning. drawText() is a simple OpenGL based function that allows rendering of some given string in the upper left corner of the viewport.
When the space bar is pressed, the game enters status RUNNING, the state transiction happens in the code that handles user input, and is not shown in this listing. While in status RUNNING, every frame, we apply a torque to the ball based on the two variables g_gameData.m_forward and g_gameData.m_strife. Those two variables are updated based on user input, their initial value is 0, which means that no torque is actually applied. The torque is applied in a way that the ball will roll to the right if g_gameData.m_strife is positive (negative action around the Z axis), and the ball will roll forward if g_gameData.m_forward is positive (negative action around the X axis). After applying the torque, we step the physical simulation forward in time using the hkpWorld function stepDeltaTime(). stepDeltaTime() is not the only way to step the simulation forward, but it is the simplest one. stepDeltaTime() is a single-threaded stepping call. Havok Physics also supports multi-threaded stepping (recommended for complex scenes), which is fully explained in the documentation.
When stepping forward the simulation, we have to pass in a value in seconds that describes the time to simulate in stepDeltaTime(). If the frame rate was stable at 60 fps, then we should always use a constant value of 0.0167 seconds (1/60) in stepDeltaTime(). This is actually what happens in our simple game FRAME_PERIOD is a constant with value 0.01667. It is recommended to use time intervals that don't vary a lot in stepDeltaTime(), in our case we are always using the same constant value.
Please note that when we apply the torque to the ball, we also pass in a time period which represents the time the action is applied for. This is needed because torques (and also forces) are appled immediately during a simulation step as impulses (instantaneous changes of the linear or angular velocity). A time interval is therefore needed to compute the correct impulse and it should usually be the simulation step time (if we want the force or torque to act on the object for the whole simulation step).
After stepping forward the simulation, we also step forward the visual debugger. This operation only happens in a debug build and will basically update the displayed scene in the visual debugger (if it is being used).
If in status RUNNING, we use the g_gameData.m_statusData variable (which is updated by our phantom shapes when the ball moves around) to understand which phantoms have been activated. We then call renderScene() with the proper flags depending on which areas have been touched.
After having rendered the scene, we perform a check to understand if the game is over (either the user won by touching both active zones or the user lost by falling off the plane). In both cases we switch to status FINISHED (so that the next frame something different will happen).
When the game is in status FINISHED, we do not step the simulation forward every frame (this effectively means that the simulation is paused, like when in status STARTING), but we keep rendering the scene with the proper colors for the zones that have been activated. In addition, we also render some text on top of the scene saying that the user should hit the space bar to play another match. When this happens, we will get back to status STARTING and the loop will begin again. This state transiction from FINISHED to STARTING happens in the user input handling functions.
For our simulation stepping we used a constant FRAME_PERIOD value assuming that the frame rate is 60 fps. To enforce this condition, we use the following code.
// Timer manager void TimerManager(int) { // post a new display operation glutPostRedisplay(); glutTimerFunc(static_cast<unsigned>(FRAME_PERIOD*1000.0f), TimerManager, 0); }
This function is registered with freeglut as the callback for timer events. At the beginning of the execution freeglut will call this function once. In our case, the last operation of the TimerManager() function is a call to glutTimerFunc() which will schedule a new timer event with a specified delay in milliseconds. The delay is computed so that practically this TimerManager() function will be called about once every 0.016 seconds (that is with a frequency of about 60 Hz ). Every time this function is called, we schedule a new freeglut redisplay event, which will basically result in a call to OnFrame(). This ensures that OnFrame() will be called about once every 0.01667 seconds.
The last interesting bit of code to look at is the code that handles user input. Here is where the state transictions from STARTING to RUNNING and from FINISHED to STARTING happen. We'll also see how g_gameData.m_forward and g_gameData.m_strife are updated based on user input.
// Key manager function void KeyManager(unsigned char key, int x, int y) { static bool fullscreen = false; switch(key) { case 'w': // move forward case 'W': g_gameData.m_forward = TORQUE_MULTIPLIER; break; case 's': // move backwards case 'S': g_gameData.m_forward = -TORQUE_MULTIPLIER; break; case 'd': // strife right case 'D': g_gameData.m_strife = TORQUE_MULTIPLIER; break; case 'a': // strife left case 'A': g_gameData.m_strife = -TORQUE_MULTIPLIER; break; case GLUT_KEY_TAB: // tabulation: toggle fullscreen if(!fullscreen) { glutFullScreen(); fullscreen = true; } else { glutPositionWindow(0,0); glutReshapeWindow(WIDTH,HEIGHT); fullscreen = false; } break; case '': // space: start or restart the game if(g_gameData.m_status == GameData::STARTING) { g_gameData.m_status = GameData::RUNNING; } else if(g_gameData.m_status == GameData::FINISHED) { g_gameData.reset(); g_gameData.m_status = GameData::STARTING; } break; case GLUT_KEY_ESC: // escape: exit the program glutLeaveMainLoop(); break; } return; } // Key up manager function void KeyUpManager(unsigned char key, int x, int y) { switch(key) { case 'w': case 'W': case 's': case 'S': g_gameData.m_forward = 0.0f; break; case 'd': case 'D': case 'a': case 'A': g_gameData.m_strife = 0.0f; break; } }
The KeyManager() functon will be called when a key is pressed. 'W', 'A', 'S' and 'D' are used to change the values for g_gameData.m_forward and g_gameData.m_strife. TORQUE_MULTIPLIER is a constant that specifies the strenght of the torque to be applied on the sphere. KeyUpManager() will be called when a key is released, if 'W' or 'S' were released we reset m_forward to 0, if 'D' or 'A' were released we reset m_strife to 0.KeyManager() also handles other events. If 'Esc' was pressed, we use the glutLeaveMainLoop() function to exit the game. if 'Tab' was pressed, we toggle fullscreen mode using the glutFullScreen() freeglut utility function. When the spacebar is pressed, we might change the game status depending on the current value of g_gameData.m_status as specified before. Note that when the game status is FINISHED and the space bar is pressed, we get back to status STARTING, but only after resetting the g_gameData structure using the reset() method.
Now that we have looked closely at Havok initialization, deinitialization, stepping during the rendering loop and user input, the reader should have a fairly clear idea about basic usage of the Havok Physics engine. An interesting exercise would be to introduce more levels in this game, creating different kinds of constraints and shapes. Everything should be fairly natural once the basic concepts are fully mastered.
This concludes our short tutorial on how to build a simple game using Havok Physics. I hope you enjoyed building this small game and that now you're eager to learn more about the engine.