- 4754ming1016@gmail.com
Enginuity, Part I
Roadmap for the Future, or How I'm Going to Lose The Next Few Weeks
Have you played Valve Software's Half Life? It's one of my favorite games. The gameplay itself is the standard for the genre (first person shooter), but the atmosphere and style of the game - the dirty, lonely bowels of the facility; the wonderful little scripted sequences (like when the scientist gets killed by the tentacle monster in Blast Pit - I love that part); the times you're left cowering in a corner, hiding from the marines and aliens engaged in a massive firefight. If it were a book, it would be un-put-downable; I came to realize this when I noticed I was chasing the Gonarch through the tunnels of Xen with my RPG, growling.
No? Perhaps, then, you like the related games, Opposing Forces or Blueshift? Guiding lost marine Adrian Shepherd past even nastier monsters than Freeman encountered, but with an enhanced arsenal at your disposal to help you out. Or taking on another day on the job as security guard Barney Calhoun, risking life and limb to get those scientists out safely (although those who *have* played it know that the scientists always meet with.. unexplained accidents, hmm?).
Prefer multiplayer? Counter-Strike is an absolute phenomenon in the online gaming world - it's one of (if not the) most popular online games worldwide. Terrorist versus Counter-terrorist in games of hostage rescue, sabotage, assassination... or more recently, I've heard of a game called Natural Selection, which manages to combine elements of Real-Time Strategy games with the popular team deathmatch.
OK, enough advertising (I take cash only, guys). All these games have a major thing in common. In each game, around 50% of the code is exactly the same as the others.
No, really. If you strip out all the 'content' - not just the maps and models, but the AI, physics, and 'gameplay' - you'll be left with pretty much the same thing in each case. A system for rendering small, urban environments, with a skeletal animation system, a bunch of special effects, networking, input, and sound. And that, ladies and gentlemen, is Valve's Half-Life engine.
Think about what I'm saying here, for a moment - Half-Life and Counterstrike are, beneath the differing models and weapons, essentially the same program. As are They Hunger and Sven Co-op, Todesangst and Day of Defeat.. a fairly large amount of diversity stemming from a single code base, a single engine
Now, apply that to your own projects. If you had a reusable engine like that, most games would just become a question of creating new maps and models (or the equivalent for your projects); menial tasks like initializing DirectX or
OpenGL and reading in settings from storage would become a thing of the past, as you'd just use the code you wrote previously.
I hate to break it to you, but all that knowledge of the Win32 API you've built up probably won't be very useful in the industry; as more and more companies lean towards the professional 'software factory' technique, they'd need to you write the Windows portion of an engine once, and after that you'd be useless. Many projects never touch underlying APIs - they use an engine to do it for them, either bought in or developed in-house. Valve, originally, bought the Quake source code to turn into the Half-Life engine - as a result they never had to touch things like OpenGL, because iD Software's code did it all for them. They just knuckled down and integrated their specific game content (AI, physics, etc) into Quake's engine interfaces. (Well, ok, so they actually *did* go in and modify the engine to support more advanced features like 16-bit textures, but my point is that they didn't *have* to).
So, in this series of articles, I'm going to do my level best to teach you how to build an engine, from scratch. I'm not just going to present you with code and say 'call function X to do funky things;' by the end of this series, you're not just going to have an engine - you're going to understand it. So listen up, maggot! :-P
It's important to realize that in practical terms, an engine can't do *everything* - the more things the engine has to support, the more low-level it becomes, until you're just writing a wrapper for DirectX/OpenGL. No, when you write an engine, you have to lay down some rules about what games can build on it - you can't build Doom 3 on the Half-Life engine, for example, because Doom 3 requires some things (like per-pixel lighting) that the Half-Life engine simply doesn't provide. More obviously, you couldn't use the HL engine if you were planning a game for GameCube, because HL doesn't support the platform.
However, it's my belief that this process of 'specialization' happens step-by-step - an engine starts, at it's lowest level, supporting every possible game, and then as you add more to it, it 'converges' on a set of limitations. You can picture it as a pyramid - the base is wide and supports the rest, while the top is a finely focused point, allowing only a very small set of games to be built on top. If we remove the top half of the pyramid, we're left with a much wider base to build a larger set of games on - those games will, however, need to do more of the building work themselves. So an engine can be progressively built up in this way, adding layer by layer.
Development requires a certain level of Enginuity
This series of mine is going, layer by layer, to take you through building an engine - as you may have guessed, I'm christening it 'Enginuity' (shoot me when I'm done writing).
Here's the 'requirements' for the engine:
- It has to be as cross-platform as possible (That is, 'desktop' platforms, not consoles - Windows 98+, Linux, and Mac OS X).
- It should be reusable.
- It should support cross-platform networking (that is, games on Linux can network with games on Windows, and so on).
- It should be designed in such a way that it's simple to add new components or replace existing ones on a project-by-project basis.
- It should run at adequate speeds on a Pentium 3 1.1ghz machine with 32mb GeForce 2 graphics card (that's our 'minimum spec,' or 'minimum technical requirements,' more or less. Much of the speed will depend on the game you run it with, though - a 3D shooter will obviously run slower than Tetris, even though they may be built on the same engine).
- It should be designed in a way that is fairly simple to understand and teach (otherwise I might just as well be talking to myself here :)
It's going to be written in C++, and we're not going to work from scratch - we'll use SDL for graphics (and, thus, OpenGL), input, and timers; SDLNet for networking; and FMOD for sound (so if you want to see someone initialize DirectX/OpenGL for the nth time, go somewhere else - there's nothing achieved by reinventing the wheel). That should pretty much fulfill the cross-platform, networked, and minimum technical requirements parts of the spec; the rest is down to us.
Feel happy - you can tell people that you're going to 'achieve reusability through modularity in an object-oriented design.' (Not that you couldn't tell people that before, of course. Unless you had your mouth full or something. My point is.. actually, never mind).
No series of articles would ever be complete without source code, and rest assured that it will be provided in ample quantities. In fact, the source may cover even more than what I talk about - you'll get to see things that I toyed with before deciding not to discuss - like the bits left over from my attempt to build the engine as a DLL under Windows, and other (usually fairly irrelevant) things that may serve to confirm your fears that I don't know what the heck I'm talking about.
I cannot stress enough that an engine is a large, fairly complex topic, and newbies should not even think about approaching it until they're totally competent with the language (C++). Put it this way: If you can work out what a 'templated class that inherits from multiple virtual base classes and overloads the pointer-to-member operator' is, then you're ready. If not, then sorry, but you simply won't understand some of the code and design techniques I'm going to use in the series. You're welcome to glean what you can, but I'm not going to pitch to the lowest level here; if I spend all my time explaining language concepts, we won't get anything done.
What's the ultimate aim of this, then? I'm thinking that building a game engine isn't nearly as much fun unless you build a game on top as well (because, let's face it, general-purpose game engines are what you make when you're out of ideas for games). So what I'll try to do is, at opportune times, build simple games (we're talking *really* simple) on the engine in it's state at the time. You can almost think of them as milestones in the development process.
That's about all I've got to say for this article. Next week, I'll give you an overview the 'foundation layer' of the engine, and cover some of the most fundamental topics, such as error logging and memory management. In the meantime, you'll want to be reading up and downloading SDKs from the following places:
http://www.libsdl.org/ - Home of both SDL and SDLNet (SDLNet in the Libraries section).
http://www.fmod.org/ - The FMOD home page.
http://nehe.gamedev.net/ - The (in)famous NeHe OpenGL tutorials (newbie to pro in sixty minutes, or something like that)Richard Fine (rfine at tbrf dot net)
Enginuity, Part II
Memory Management, Error Logging, and Utility Classes;
or, How To Forget To Explode Your Underwear
Download the source code for this article here.
Hello! Welcome to part 2 of my silly-named series. This article we're going to cover the layout of the engine as a whole, and then move on to two of the most vital parts to an engine: Memory Management and Error Logging.
If you haven't sat down and look at any of the pre-packaged libraries I mentioned last time yet (SDL, SDL_Net, FMOD, and
OpenGL ), don't worry. We won't be going near them this time, with the exception of a cursory glance when I show you the design of the monster we're going to create.
Enginuity: Overview
Yup, that's it. Take a step back and reflect.
The first thing that should jump out at you is the grey bar down the right-hand side. That represents the Application itself - while the rest of the engine will stay the same between projects, the Application part changes - that's how each game is made to be different. The program starts at the App Entry Point (you may know it as main() or WinMain()), and passes control out to the Kernel (almost immediately). Later on, various calls are made back into the application itself, to request the specific bits of data that the engine works with.
Hopefully, you'll also see how it breaks down into some fairly obvious chunks. At the bottom is the double-outlined 'KERNEL' layer - that's our 'foundation layer,' and provides services to the rest of the engine. Next up is the 'task pool,' which contains the tasks to render the screen, update input devices, etc - and also, the 'Appstate' task. The AppState system (or 'Gestalt,' as I like to call it) allows you to switch the 'mode' that your program is in - for example, changing from being in-game to being in a menu would most likely be a change in application state. The AppState system calls back to the AppState factory in the application, allowing you to provide the states for the engine to use.
Next up is the CLIENT/SERVER system. Now, just to get something straight - Client/Server doesn't have to mean *network* Client/Server. Anything providing a 'service' is a Server, and anything using that 'service' is a Client - in truth, the relationship between the Kernel and the rest of the engine is a client/server relationship, in that the Kernel provides 'services' to the engine. In this particular situation, the Server in the C/S system provides a 'common gamestate' - so all clients using that server will effectively be 'in the same game.' Even for a single-player game, this works - it just means that the common gamestate is only being used by one client. The Server will be the point from which AI code is called, and game rules are checked up on - it doesn't make conceptual sense to have the client do this.
Having said all that, the C/S system *does* provide the network support in the engine. If the Client and Server in a game are on the same machine, then the network system picks it up and uses the LocalComm boxes (which move messages from A to B directly in system memory, rather than sending them out to the network drivers, round the loopback, and in again). If they aren't - that is, the player is joining a network game - then it uses the RemoteComm boxes to handle connections to other computers. Both RemoteComm and LocalComm can be used simultaneously (i.e. when hosting *and* playing on one machine).
The C/S boxes also handle common networking tasks - checking that a client is running the same version of the app as the server, enumerating games on the LAN, and so on. They do this through the use of 'messages,' which are simply a numerical code attached to a blob of data. As such, certain message codes are handled by the boxes themselves, but for everything else, there's 'handlers.' And you'll like this: you can pick a number for your message type (assuming it's not already in use), and register a 'handler' for it. That handler gets called whenever a message with that code is received - so you can register IDN_CHAT_MESSAGE to call the handler RecieveChatMessage() (which, clientside, would display the message or something, while serverside it might filter it for profanity or server commands). Because the message handlers are in the app itself, it's more or less totally extendable.
Finally, there's the gamestate itself. The gamestate (as you probably already know) is the blob of data that describes everything you could need to know about the game - not just things like player scores or elapsed time, but player positions or world collision data. It comes in two flavours - both of which are usually pretty similar - one for the client, and one for the server. The information that each part of the game needs to work with is often different (albeit, not by much). An example: in a multiplayer game, the Server will need information on all players, while the Client may only need information about it's own player. A more important example would be bots and AI - all a bot's AI variables should be stored as part of the game state, but there's no point sending that info to the client (as they'll only need the bot's position, say). In any case, you're responsible for creating each gamestate (through the Gamestate Factory), so it's more or less up to you.
Setting up the Build Environment
You're probably going to need some kind of coherent 'project' to keep all your files together (unless you're some kind of hardcore kernel hacker / masochist). I'm familiar with MSVC6 so that's what I'll use, but most of this is applicable to you if you use something else (such as Borland C++ Builder or dev-c++). And although this is all meant to be cross-platform, I'm going to assume we're building under Win32.
Firstly, creation of the project itself. The project type should be 'Win32 Application,' and should be an 'Empty Project.' MSVC generates the project files in the place you picked, and then we dive into the project settings: SDL demands that we use the multithreaded version of the runtime library (Project -> Settings -> C/C++ -> Category: Code Generation -> Use run-time library: Multithreaded DLL).
You also need to set up the linker to link the engine with the required libraries. Either using #pragma commands, or going through the 'Linker' tab in project settings, add sdl.lib, sdlmain.lib, opengl32.lib, glu32.lib, fmodvc.lib, and sdl_net.lib to the list. I assume you already set up the locations of the libraries to be included in the search path (Tools->Options->Directories), along with the include files?
The last thing I'd recommend is to set up the debugging environment a little; specifically, the working directory. Given that you're going to be working with both Debug and Release builds over time, and each build is going to share the same resources, you want to put those resources in a common place. It's also quite useful to keep assets separate from your code. I create a 'runtime' folder as a subdirectory of the project folder, and build up my 'install' of the project's assets in there; the debugger gets set up to use the 'runtime' folder as the working directory (Project->Settings->Debug->Working Directory).
Now that we've got that out of the way, let's move on to the first of our topics du jour - Memory Management.
Memory Management
Memory is one of your top resources. It's your workspace; it's the floor of your room, where you can put toys while you play with them. And if you don't put the toys away once in a while, you'll run out of space and won't be able to play with any more toys. Until your mother comes up with a black binbag and starts putting everything into it shouting that you.. sorry, childhood flashback. *Ahem*. Moving on.
One of the most disrepectful things you can do to a player's system is leak memory. Your program's leaving mess on the pavement, and you're not scooping up after it. Gradually, the player's system gets slower and slower as the OS pages more and more memory to disk; until eventually they have to stop playing and reboot.
So here's the first thing our memory manager needs to do: track memory usage. It needs to keep an eye on all the blocks of memory we carve out, to ensure that said blocks get released again in the proper fashion.
Now, I've been a little, uh, 'economical with the truth.' We don't need to track *all* our memory. There's two types of memory involved: stack memory, and heap memory. Heap memory we worry about; stack memory we don't. There's also a few things that it's fairly impractical to manage - things like Singletons, or other 'large objects' that are so obvious that failing to release them would cause other noticable bad behaviour (things like the Kernel or Application objects). But for most of the objects our engine handles, they could slip away into obscurity at any time, never to be seen again... We need to keep a list of pointers to our objects.
Here's how we approach it. We create a new class, IMMObject:
class IMMObject { private: static std::list<IMMObject *> liveObjects; protected: IMMObject(); virtual ~IMMObject(); public: }; //a 'static initialiser' is needed in one of the source files //to give the std::list a definitive presence std::list<IMMObject *> IMMObject::liveObjects; IMMObject::IMMObject() { liveObjects.push_back(this); } IMMObject::~IMMObject() { // We add an empty virtual destructor to make sure // that the destructors in derived classes work properly. }Righty ho. To clean up all objects floating around at the end of the program, we loop through the liveObjects list, deleteing each pointer - and voila, no memory leaks. So that's ok - except, we can only do that at the end of the program. What if we're in the middle of the game and find ourselves running low on memory? We can't just delete all our objects and start again, but there'll probably be some objects we *could* delete, if we knew about them. So here we get to the next requirement of our memory manager: Garbage Collection. We should be able to remove from memory all the 'orphaned' objects that aren't needed any more.
But wait: how do we tell if an object isn't needed any more? We could have some kind of a flag that the object's user sets when it's done with it, but that's potentially disasterous if objects are being shared around (and they most certainly will be). So, coupled to Garbage Collection is a third requirement: Reference Counting. A system for tracking how many things are using an object, and for marking it as 'collectable' when they all say they're done. So, we try the IMMObject class again:
class IMMObject { private: static std::list<IMMObject *> liveObjects; long refCount; protected: IMMObject(); ~IMMObject(); public: void AddRef(); void Release(); static void CollectGarbage(); }; IMMObject::IMMObject() { liveObjects.push_back(this); //update the constructor to initialise refCount to zero refCount=0; } void IMMObject::AddRef() { ++refCount; } void IMMObject::Release() { --refCount; } void IMMObject::CollectGarbage() { for(std::list< IMMObject *>::iterator it=liveObjects.begin(); it!=liveObjects.end(); ) { IMMObject *ptr=(*it); ++it; if(ptr->refCount<=0) { liveObjects.remove(ptr); delete ptr; } } }There are two problems with this approach. Firstly, there's the rather icky construction in the CollectGarbage() function - the iterator has to be incremented at a weird time to make sure it doesn't get stepped on by the call to remove(). Also, this method is bad when the ratio of live objects to dead objects is high: when you've got 5000 objects being managed, but only 10 of them need removing, that's still 5000 objects being checked; not good. A better solution is to give the liveObjects list a parter - deadObjects:
class IMMObject { private: static std::list<IMMObject *> liveObjects; statid std::list<IMMObject *> deadObjects; long refCount; protected: IMMObject(); ~IMMObject(); public: void AddRef(); void Release(); static void CollectGarbage(); }; std::list<IMMObject *> IMMObject::deadObjects; void IMMObject::Release() { --refCount; if(refCount<=0) { liveObjects.remove(this); deadObjects.push_back(this); } } void IMMObject::CollectGarbage() { for(std::list<IMMObject *>::iterator it=deadObjects.begin(); it!=deadObjects.end(); it++) { delete (*it); } deadObjects.clear(); }Much neater, don't you think? (There's still an optimisation there - liveObjects.remove still searches the list for the object (which can take even longer than the initial method, in fact), so the object should store some kind of iterator allowing the list to remove it directly. But I leave it up to you.)
There's two more things we should add to the IMMObject class before we move on. Firstly, a fail-safe function, to be called at the end of the program, that will purge the liveObjects list (and log anything unreleased, because if there's anything still around at that time then something screwy's going on). Secondly, if we're going to track all the objects that are around, we might as well lay a base for tracking memory usage, too; so we add a pure virtual function, for derived classes to implement, that returns the size of the object.
class IMMObject { private: static std::list<IMMObject *> liveObjects; static std::list<IMMObject *> deadObjects; long refCount; protected: IMMObject(); virtual ~IMMObject(); public: void AddRef(); void Release(); static void CollectGarbage(); static void CollectRemainingObjects(bool bEmitWarnings=false); virtual unsigned long size()=0; }; //define a quick macro to make things easier on derived classes #define AUTO_SIZE unsigned long size(){return sizeof(*this);} void IMMObject::CollectRemainingObjects(bool bEmitWarnings) { CollectGarbage(); for(std::list<IMMObject*>::iterator it=liveObjects.begin(); it!=liveObjects.end(); it++) { IMMObject *o=(*it); if(bEmitWarnings) { //log some kind of error message here //we'll be covering how to log messages later this article } delete o; } liveObjects.clear(); }And there we go: a nice little base class to automatically memory-manage our objects. You might want to polish it up a bit - inline the AddRef()/Release() functions, for example - but again, I leave it up to you.
Smart Pointers
Those memory-managed objects are nice, but they're a bit of a pain to use on their own. Having to call AddRef()/Release() every time you deal with one isn't just tedious - it's asking for trouble. What would be good would be if AddRef and Release would just sort of.. call themselves, and that's where Smart Pointers come in.
Smart Pointers are objects that behave (and, indeed, can be treated) just like pointers - except that they do more than plain variable pointers. In our case, we can set up a Smart Pointer class to call AddRef() on an object when it's assigned to it, and Release() when it lets go of it. Then we can just do 'ptr = obj' in code, and the smart pointer takes care of the reference counting for us!
The faint-hearted amongst you: be warned that this next section uses most of C++'s 'advanced' features. If you're not comfortable with the *whole* language - including operator overloading and templates - leave now, and don't come back till you've bought several heavy books on the subject. Whether you beat yourself to death with them or actually read them is up to you. The rest of us: onward!
Now. The first, most obvious thing to say, is that smart pointers will have a pointer to the object they're pointing at. (I said obvious, not easy). That is, a smart pointer object set to point at object 'cheese' will need to have a pointer member variable that actually points to 'cheese' - without it, we wouldn't get very far. The smart pointer class itself acts something like a wrapper for that pointer. But I ask you: what type should the pointer be? Veteran C programmers amongst you might suggest void*, but we can do better than that. The more astute of you may well say that IMMObject* would be suitable - it's better than void*, but they both suffer from the same problem, which is that I can mix my object types. I can take a pointer to an object of 'CMonkey,' and assign it to a pointer which something expects to have an object of type 'CTable.' (In short, they lack type safety). The best solution is to use templates, and have each smart pointer custom-built to store a particular type of object pointer. So here's the initial code:
template<class T> class CMMPointer { protected: T* obj; public: //Constructors - basic CMMPointer() { obj=0; } //Constructing with a pointer CMMPointer(T *o) { obj=0; *this=o; } //Constructing with another smart pointer (copy constructor) CMMPointer(const CMMPointer<T> &p) { obj=0; *this=p; } //Destructor ~CMMPointer() { if(obj)obj->Release(); } //Assignement operators - assigning a plain pointer inline operator =(T *o) { if(obj)obj->Release(); obj=o; if(obj)obj->AddRef(); } //Assigning another smart pointer inline operator =(const CMMPointer<T> &p) { if(obj)obj->Release(); obj=p.obj; if(obj)obj->AddRef(); } };OK. That will now let us create a smart pointer object, and assign to it an IMMObject* (the thing you assign to it has to be derived from something with AddRef()/Release() methods, at least, otherwise it won't compile). Still, it's pretty useless without some other basic pointer operations - like accessing the pointer. D'oh! Never mind. We can also take the opportunity to catch null pointer exceptions - our accessor functions can simply check that the pointer isn't NULL before returning it. Watch and learn:
template<class T> class CMMPointer { protected: T* obj; public: //Constructors, destructor, and assignments are same as last time //Access as a reference inline T& operator *() const { assert(obj!=0 && "Tried to * on a NULL smart pointer"); return *obj; } //Access as a pointer inline T* operator ->() const { assert(obj!=0 && "Tried to -> on a NULL smart pointer"); return obj; } };Almost there now. We're just missing a few more things - like, for example, a simple way to convert back to normal pointers, or a way to check whether the pointer is NULL without causing an assert() in the process :-)
template<class T> class CMMPointer { protected: T* obj; public: //Constructors, destructor, assignments and accessors same as before //Conversion - allow the smart pointer to be automatically //converted to type T* inline operator T*() const { return obj; } inline bool isValid() const { return (obj!=0); } inline bool operator !() { return !(obj); } inline bool operator ==(const CMMPointer<T> &p) const { return (obj==p.obj); } inline bool operator ==(const T* o) const { return (obj==o); } };That should about do it.I've not included other operators - such as pointer math ops (+/-) - because it doesn't really make sense with smart pointers. You're meant to be pointing to objects, not arbitrary locations in memory. What we've got there, though, should be enough for 95% of the time - it should replace your average normal pointer absolutely transparently, with no need to change things - the only places where it's more complex is when converting one pointer type to another, and the aforementioned pointer math. There's ways of doing both.
When should smart pointers be used? The simple answer is: any time you need to 'retain' a pointer - keep it for any length of time that might include a garbage collection sweep. You don't need to use smart pointers if you're just using the pointer in a single function and then dropping it from the stack. That can be particularly useful when deciding on parameters for functions: if SomeFunction(CMMPointer<SomeObject> &p) doesn't keep the pointer somewhere once it's returned, then it'd probably be easier to have it as SomeFunction(SomeObject *p). Accessing the object through a smart pointer obviously incurs a small speed cost, but it builds up; you should bear that in mind in speed-critical parts of the engine.
Now that we have a smart pointer, it's time to create our very first memory-managed object - another part of the memory-manager system! :P
Aside from actual game objects, the second most common dynamically allocated objects in our engine will be buffers. Buffers for decompressing resources, for serialising network messages.. you name it, there's a buffer for it. But you can't derive int[1000] from IMMObject - looks like we need another wrapper. Two, in fact - one for fixed-size buffers, and one for dynamic-sized (runtime-sized) buffers. The fixed-size one isn't really necessary, but it's *very* easy to do. These 'buffer wrappers' are the objects I affectionately term 'blobs,' and look like this:
template<class T, int i> class CMMBlob : public IMMObject { protected: T buffer[i]; public: inline T& operator [](int index) { assert(index<i && "Bad index on CMMBlob::[]"); return buffer[index]; } inline operator T*() { return buffer; } AUTO_SIZE; }; template<class T> class CMMDynamicBlob : public IMMObject { protected: unsigned long dataSize; T *buffer; public: inline T& operator [](int index) { assert(index<dataSize && "Bad index on CMMDynamicBlob::[]"); return buffer[index]; } inline operator T*() { return buffer; } CMMDynamicBlob(unsigned long size) { dataSize=size; buffer=new T[size]; assert(buffer!=0 && "DynamicBlob buffer could not be created - out of memory?"); } ~CMMDynamicBlob() { if(buffer)delete[] buffer; } unsigned long size() { return dataSize+sizeof(this); } inline unsigned long blobSize() { return dataSize; } };You see now why I said the fixed-size blob would be easy? That's just how easy simple objects are to handle - you can just group together a few variables in a class and have them memory-managed as a discrete object. The fixed-size blob takes the buffer type and size as template parameters; the dynamic blob takes the buffer type as the template parameter, and the buffer size as the constructor argument (so you can work it out at runtime). Note that the fixed-size blob uses the AUTO_SIZE macro, defined above, while the DynamicBlob reports the *actual* size of the object - including both the allocated buffer and the wrapper. If you just want the size of the buffer itself, you should use the seperate blobSize() function - because requiring you to remember to subtract the 8 bytes or so that the wrapper uses is, as usual, asking for problems.
Each class provides access control, for two reasons: firstly, it's far too easy to severly mess things up by reallocating the buffer pointer or deleting it yourself (not that you'd ever want to do that, but accidents happen), so by forcing access to go through the [] and T* operators, we completely protect buffer itself from the outside world. It's still possible to do something like "delete (sometype*)(*obj);" but it's less likely because the syntax is more unweildy. The second reason is those asserts - we have the opportunity to check that we're not trying to access memory outside of the buffer.
Woo! That, ladies and gentlemen, is the end of Memory Management. We now have a (relatively) robust system for tracking objects within our engine, and trust me, we'll be using it. It's totally independent of any other library or class (with the exceptions of the assert() calls), making it ideal for reuse. It doesn't depend on any platform-specific functionality, such as byte order. Looks like we're meeting the spec, then. On to...
Error Logging
There comes a time in every young engine's life when... things don't quite work as they should. Sockets don't connect, resources can't be found... if we're ever going to have a hope of finding and fixing the problems, we are of course going to need to know about them. So we have a system for recording errors as and when they occur - 'error logging.' In truth, logfiles can and should be used for recording all kinds of events, not just errors - if something's going wrong and not recording what, recording the things that *are* working will help you find the problem by process of elimination.
We could just create an ofstream object and store it in a global variable - such a method works (up to a point) but is pretty basic. We can do better than that! Our logging system will support multiple logfiles, predefined localisable messages, and parameter replacement.
Multiple logfiles are just useful. In extreme cases this could mean one logfile per subsystem - one each for video errors, sound errors, etcetera; I'm not going to go that far, and just have 3 logfiles (CLIENT, SERVER, and APP). CLIENT and SERVER will record - you guessed it - log files relevant to the Client or Server portions of the program (Client includes all the Video, Sound, and other 'player-end' tasks - Server will tend to be less used, but will record connections being opened and closed, games being started and stopped, and AI/physics messages), while APP will record Kernel-level messages, along with those messages that don't seem to 'fit' into CLIENT and SERVER. You can, of course, record a message to more than one logfile at a time.
"Predefined localisable messages," more often known as a "string table", allow you to store some common strings somewhere, load them in, and then reference them by ID number - rather than hard-coding the message into your app. This saves a large amount of space (because strings aren't being duplicated), and also makes it very easy to translate all the messages into another language.
"Parameter replacement" is the technique demonstrated by the old C string functions like printf() - special 'field codes' in the string get replaced with actual values that get passed in seperately. So, strings can be more generalised - rather than needing one string for each error number, you could just use a single string with a field code ("Error code %i") and pass the error number in alongside it. We'll be using the printf() syntax for field codes - mainly because we'll be using a special form of the printf() function to build our messages from the strings and arguments. Furthermore, you can store messages with field codes in the strings table - and that's where things start getting really interesting.
For the time being, though, let's get started. There's just one last thing I need to mention - the method of storing the strings file. It's generally a good idea if the strings can be stored where curious users can't tamper with them - adding or removing field codes where they're not expected could cause some serious problems. Under Linux and MacOS, we've not really got anywhere tamper-proof to keep the strings - we'll have to make do with a read-only file. On Windows, however, we can store the string table as a resource, built into the executable. That's why you'll see two versions of the LoadStrings() function; one reads the strings from the resources area, and the other reads from a file on disk. Conditional compilation is used to compile the right one on the right platform.
Enough waffle. Here's the class:
//first, a few predefined constants const int LOG_APP=1; const int LOG_CLIENT=2; const int LOG_SERVER=4; //LOG_USER is used to display the log message to the //user - i.e. in a dialog box const int LOG_USER=8; #define MAX_LOG_STRINGS 256 class CLog { protected: CLog(); std::ofstream appLog; std::ofstream clientLog; std::ofstream serverLog; std::string logStrings[MAX_LOG_STRINGS]; bool LoadStrings(); public: static CLog &Get(); bool Init(); void Write(int target, const char *msg, ...); void Write(int target, unsigned long msgID, ...); };Pretty straightforward. The log is a Singleton, which is why you see a protected constructor and a static Get() function - trying to have more than one CLog object at any time simply wouldn't work, because they'd both be trying to open the same files, and would cause a sharing violation. The files themselves are accessed through std::ofstream objects. The strings table, once loaded by the LoadStrings() function, is stored in the logStrings array - you could use a vector, but I didn't (because it doesn't *really* need to be dynamically expandable.. when are you going to need more than 256 slots for log messages?). Then, moving down to the public section of the class, there's the destructor and aforementioned Get() function; then, an Init() function, which is responsible for actually opening the logfiles and calling the LoadStrings() function - this isn't done by the constructor, because if it fails, there's no way of knowing; with an explicit Init() function, we're reminded to check the return code. It also gives us total control over when the log is started up.
Finally, the Write() functions. Each takes an int that tells you where the message should be logged to (by ORing together the LOG_* codes defined above); then, one takes a string pointer (for hard-coded strings), and one takes a unsigned long (for string table ID number). Then, each takes some kind of nebulous '...'. What's that about, you ask?
'...'s are known as 'ellipses,' and are how we achieve 'variable argument lists.' Given that the message we're using may contain 5 field codes or 50, the number of values we pass with it will vary - and that includes the situation where there are no arguments. So, we use the '...' to denote that any number of arguments can follow the fixed ones (and there must be at least one fixed one, even if it's just a dummy one). We don't actually need to process the list ourselves in the Write() functions - a good thing, because variables being passed in lose all type information and become a pain to work with - we just need to retrieve a pointer to the list, and pass it on to the special printf() function, vsprintf().
Here's the functions themselves:
CLog::CLog() { //the constructor doesn't do anything, but we need //it for our singleton to work correctly } CLog &CLog::Get() { static CLog log; return log; } bool CLog::Init() { appLog.open("applog.txt"); clientLog.open("clntlog.txt"); serverLog.open("srvrlog.txt"); //user errors get logged to client //load the strings file if(!LoadStrings())return false; return true; } void CLog::Write(int target, const char *msg, ...) { va_list args; va_start(args,msg); char szBuf[1024]; vsprintf(szBuf,msg,args); if(target&LOG_APP) { appLog<<szBuf<<"n"; #ifdef DEBUG appLog.flush(); #endif } if(target&LOG_CLIENT) { clientLog<<szBuf<<"n"; #ifdef DEBUG clientLog.flush(); #endif } if(target&LOG_SERVER) { serverLog<<szBuf<<"n"; #ifdef DEBUG serverLog.flush(); #endif } if(target&LOG_USER) { #ifdef WIN32 MessageBox(NULL,szBuf,"Message",MB_OK); #else #error User-level logging is not yet implemented for this platform. #endif } } void CLog::Write(int target, unsigned long msgID, ...) { va_list args; va_start(args, msgID); char szBuf[1024]; vsprintf(szBuf,logStrings[msgID].c_str(),args); Write(target,szBuf); } #ifdef WIN32 //under Win32, the strings get read in from a string table resource bool CLog::LoadStrings() { for(unsigned long i=0;i<MAX_LOG_STRINGS;i++) { char szBuf[1024]; if(!LoadString(GetModuleHandle("engine"),i,szBuf,1024)) break; //returning 0 means no more strings logStrings[i]=szBuf; } return true; } #else //other platforms load the strings in from strings.txt bool CLog::LoadStrings() { std::ifstream in("strings.txt"); if(!in.is_open())return false; DWORD index=0; while(!in.eof()) { char szBuf[1024]; in.getline(szBuf,1024); stringsFile[index++]=szBuf; } return true; } #endifAfter reading that, there's probably a few things you're wondering about:
Firstly, that va_list business. That's how we work with variable argument lists; we create a pointer of type va_list (it maps to char*, in the headers), and use the va_start() macro to get it pointing to the right place, by passing va_start the argument *immediately before* the list - va_start gets a pointer to that, adds on its size, and stores that in va_list (or something along those lines, at least). We can then pass va_list to vsprintf(), which happily processes it for us.
Next, the flush() calls. In theory, this should make sure that the message you've just written actually gets saved to disk, rather than being stored in a cache somewhere; given that your app is still unstable in debug builds, a crash would cause you to lose log messages in that cache (and those log messages would probably tell you how and why you crashed). I say 'in theory,' because it didn't actually *work* for me; I left it in because it's *meant* to. Tracing the flush() call through the documentation gets to basic_streambuf::sync(), which "endeavours to synchronize the controlled streams with any associated external streams," that is, it tries to get the in-memory object into the same state as the file on disk (by changing the file on disk). I would guess that it's failing there; if you can tell me why, kudos.
I didn't mention the LOG_USER option before. You should have figured out what it does by now, but if you haven't - it displays the message to the user (i.e. in a pop-up message box). You can use this for the really important messages, like 'The game failed to start because it's got a hangover.' However, implementation of this is platform-dependent, which is why you see the #ifdef WIN32 lines in there. There's also a #error statement - if you try and build this on a platform other than Windows at the moment, it won't let you build because that LOG_USER functionality isn't implemented. All non-windows users need to do is add an #elif defined MY_PLATFORM_FLAG before the #else line, and they'll be free to implement the message box for their own platform; I've not done any platform other than Windows because I'm not confident I'll get it right. :)
It's probably worth noting that the string-table-based version of the Write() function uses the plain version to do the actual logging. It's nice like that. It's also an example of passing no arguments in the variable-argument list; the plain Write() function will handle that fine, as you will see.
Miscellanous Utilities
Hmm, that could probably be a good name for a geek band :)
There are a few base classes that will be used from time to time across the engine; there are a few more which, while I won't cover here, use the same or similar techniques. Many of these base classes are provided by common libraries such as boost (or, in fact, the STL itself) - but I'm here to educate, and a couple of implemented design patterns never hurt anyone. It may not be totally obvious how some of these will be useful at this stage, but I will be using them all; as such, it will help if you can read, understand, and have them ready to hand in later articles.
Functors
A functor (or, to be more precise, a 'bound functor') is a way of wrapping up a function somewhere in an object. Have you ever tried to work with a pointer to a member function? You can't, for example, dereference a function pointer to CLog::Write() without a pointer to the object it needs to be called on (otherwise, what does the 'this' pointer equal?). With functors, you can wrap up the pointer to the object *and* the pointer to the member function within that object, and use the functor to call it in an easy way. So, we have our functor base class:
class Functor : public IMMObject { public: virtual void operator ()()=0; };Firstly, it's memory-managed, meaning that we can throw as many functors as we want around the place and the engine will clean up after us. Secondly, though, it's very obviously an abstract base class for something else. Why? Because the class we're about to derive from it is a templated class:
template<class T> class ObjFunctor : public Functor { protected: T *obj; typedef void (T::*funcType)(); funcType func; public: AUTO_SIZE; ObjFunctor(T *o, funcType f) { obj=o; func=f; } void operator ()() { (obj->*func)(); } };That's more like it. The pointer-to-member-function type is typedef'd for easy use; the AUTO_SIZE macro makes its appearance to satify IMMObject::size(). But what's with this base->derived business? Why bother with the base class at all, and not just have ObjFunctor derived from IMMObject?
It's like this. When you create an ObjFunctor, you'll give the type of the object that it works with - going from our earlier example, ObjFunctor<CLog> will let you store pointers to functions on any CLog object. Now, let's say you want to keep a generalised list of ObjFunctor objects - say, a list of functions to call in the event that something happens - you'll find you won't be able to. Your std::list< ObjFunctor * > tells you that ObjFunctor requires a template parameter; but if you give it that, you fix the list as being a list of <CLog> function pointers, or whatever you specify. That's not much good - you want to be able to point to any function, anywhere. That's why we use the base class; you create your list as std::list< Functor * >, and then can store any ObjFunctor in it - and the fact that the () operator is virtual means that calls get passed down in the correct way to the ObjFunctor class.
Lastly, as you may have guessed from the existence of the () operator, the syntax for calling a Functor object (not a pointer, mind) is exactly the same as calling a normal function - fMyFunctor() will call whatever function fMyFunctor is bound to. If fMyFunctor is actually a pointer, rather than an object (as will often be the case), (*fMyFunctor)() will do the trick.
There's one more special case. The ObjFunctor doesn't take into account the reference-counting system; the object that it points to could be freed without its knowledge. Thus, we derive a second class from Functor:
template<class T> class MMObjFunctor : public Functor { protected: CMMPointer<T> obj; typedef int (T::*funcType)(); funcType func; public: AUTO_SIZE; MMObjFunctor(T *o, funcType f) { obj=o; func=f; } int operator ()() { return (obj->*func)(); } };Near-identical, except that the obj pointer is now a CMMPointer. You won't necessarily want to use this all the time, as the other version is slightly faster.
One useful feature I tried to implement (and couldn't, because MSVC doesn't support partial specialization) was the ability to set the functor's return type. If you're using a compiler which supports it, here's a hint: all the Functor classes need to be specialized for the void return type. This is because return (obj->*func) doesn't compile if func returns void. So, you'd have Functor<class R>, and then ObjFunctor<class T, class R> (which is where MSVC breaks down, because I need to specify the 'void' for R but can't specify anything for T), and so on.
So, the Functor allows us to wrap up a function inside an object. It could be useful for, say, callback handlers when a button is pressed, in a UI system.
Singleton
I give credit for this to Scott Bilas, who presented the technique in Game Programming Gems (an excellent series of books, if I may say so).
You should already know what a singleton is (and if you don't, I apologise - my previous mentioning of the term may have confused you a little). However, it's a bit tedious (to say the least) to have to implement the same singleton code, each time you want a new class as a singleton. Ideally, there should be a Singleton base class - and with the magic of templates, there is.
template<typename T> class Singleton { static T* ms_singleton; public: Singleton() { assert(!ms_singleton); //use a cunning trick to get the singleton pointing to the start of //the whole, rather than the start of the Singleton part of the object int offset = (int)(T*)1 - (int)(Singleton <T>*)(T*)1; ms_singleton = (T*)((int)this + offset); } ~Singleton() { assert(ms_singleton); ms_singleton=0; } static T& GetSingleton() { assert(ms_singleton); return *ms_singleton; } static T* GetSingletonPtr() { assert(ms_singleton); return ms_singleton; } }; template <typename T> T* Singleton <T>::ms_singleton = 0;To use the singleton class, we derive a class SomeClass from Singleton<SomeClass>. One thing to note about this type of singleton is that we - not the loader - are responsible for creating the singleton and destroying it again when we're done. We create it simply by calling new SomeClass() somewhere in code - the constructor takes care of the rest, so we don't even need to store the pointer that new returns. To destroy it, we call delete SomeClass::GetSingletonPtr(); that also sets the singleton pointer back to zero, so we can recreate the singleton if we want.
The Singleton will come in useful for many key engine systems, such as the kernel or settings manager, of which we only ever want one.
Ring buffer
This one I came up with purely on my own. :)
A ring buffer is, as the name suggests, a 'ring-shaped buffer' - a circular buffer, which has no specific start or end. You create it to store a maximum number of a specific type of object, and then read and write to it like reading or writing to a stream. Obviously, the buffer has to have it's block of storage space as a plain, linear block of memory internally; but it stores read/write pointers, which it 'wraps' to the beginning of the block whenever they pass the end. Provided that the read pointer doesn't catch up to the write pointer (i.e. the buffer is empty), or vice versa (i.e. the buffer is full), then the buffer seems infinitely long. There's no time-consuming memcpy() operations involved; the only limitation is that the size must be determined at compile-time, rather than at runtime (although even that could be fixed, if you needed to).
template<class T, unsigned long bufSize> class ringbuffer { protected: T buf[bufSize]; unsigned long read, write; public: ringbuffer() { read=0; write=1; } bool operator << (T &obj) { buf[write]=obj; ++write; while(write>=bufSize)write-=bufSize; if(write==read) { --write; //make sure this happens next time while(write<0)write+=bufSize; return false; } return true; } bool operator >> (T &res) { ++read; while(read>=bufSize)read-=bufSize; if(read==write) { ++write; //make sure this happens next time //we call and the buf is still empty while(write>=bufSize)write-=bufSize; return false; } res=buf[read]; return true; } unsigned long dataSize() { unsigned long wc=write; while(wc<read)wc+=bufSize; return wc-read-1; } void flood(const T &value) { //loop through all indices, flooding them //this is basically a reset read=0; for(write=0;write<bufSize;++write) { buf[write]=value; } write=1; } };So, reading and writing to the buffer is done through the >> and << operators. There's also a dataSize() function, which will tell you how many elements are available for reading, and a flood() function, which is useful for wiping the buffer (initialising all slots to a particular value).
The ring buffer will prove useful in the C/S systems, eventually. It's like a FIFO (First In First Out) queue, but it doesn't need to allocate any memory, making it quite a bit faster.
Coda: Gangsta Rappa Game Developer
(Geddit? *sigh*)
That's all for this time. Next time we'll finish the kernel layer, I think, if you're up for it - the Settings system and Task Manager / Kernel Core systems await. But now I'm going to go check my email...
- 请在左侧登录后再来发评论
ming1016@gmail.com 关注Enginuity, Part III
Profiling, Settings, and the Kernel;
or, Stop Watching That Military Control Panel
ADVERTISEMENT '); } if ( ShockMode ) { document.write(''); } else if (!(navigator.appName && navigator.appName.indexOf("Netscape")>=0 && navigator.appVersion.indexOf("2.")>=0)){ document.write('
');
}
ming1016@gmail.com 关注Enginuity, Part IV
The Entry Point and Task Pool; or, Swim Your Way In
Still reading these? I must be better than I thought. This article we'll take all the code we've produced so far - the Foundation Tier of the Enginuity engine - and actually make an executable program with it. Then we'll put together some of the 'system tasks' that any game will need.
But of course, if I'm going to take the code we've produced so far, you'll need to know about it. So go read the other articles, if you haven't already.
Entry Point
The Application Entry Point is the place in your program where it all begins. Traditional C/C++ programs have an entry point called 'main' - Win32 programs have 'WinMain,' and so on and so forth. If you were to represent your program as a tree, where nodes are functions that call other functions, the entry point would be the very root of the tree. Before now, Enginuity didn't have an entry point, so we couldn't build it into an executable.
We're not about to give Enginuity an entry point, either. As an engine, it shouldn't have one; as it is now, we can build it as a library, and have a proper program start the engine whenever it wants. We give extra control to whoever wants to use the engine - perhaps an anti-piracy system needs to be initialized before the engine starts, for example.
So what we'll be doing here is looking simply at a sample program that makes use of the engine. The engine could be in a library or DLL, or it could be simply build as part of the project; it doesn't matter. Personally, I'm building the whole thing in one project, and just splitting the source files into 'Engine' and 'Game' folders.
Given that we're aiming for a relatively cross-platform engine here, we have a problem. I already mentioned a discrepancy between entry point functions on Win32 and other systems - they have different names (and different parameters). Do we provide both main() and WinMain() functions? Do we use some kind of conditional-compilation trick?
Neither. SDL has already solved the problem for us. It contains the code to 'insulate' us from the underlying system - so that when it gets to us, we always use a main() function. SDL provides the 'translation' from WinMain() to main() under Win32, and so on for other platforms. All we have to do is make sure we link to sdlmain.lib, and that we're including sdl.h. Here's the main() function we're going to use:
int main(int argc, char *argv[]) { new CApplication(); CApplication::GetSingleton().Run(argc,argv); delete CApplication::GetSingletonPtr(); //clean up any remaining unreleased objects IMMObject::CollectRemainingObjects(true); return 0; }Before we get into the body of the function itself, I'll just say this: make absolutely sure that the main() function has a header like the one above. Same return type, same name, same argument types. If you get errors about 'sdl_main is undefined,' check here. (The truth is that sdl_main.h includes a macro to turn any function named main() into one named sdl_main(), so that it doesn't get confused with the main() function that SDL provides. As far as I can tell, an unfortunate side effect of this is that you shouldn't use the name 'main' for any functions or variables; but frankly, I consider it a small price to pay).
OK. You're probably wondering what this CApplication class is. You've probably gathered that it's a Singleton; it represents your program. It's often useful to encapsulate (wrap up in a class) the 'application' itself; you get the benefits of construction/destruction, as well as extra control over lifetimes (as we'll see in a moment). The CApplication class, then, is the 'meat' of the program.
So the first thing we do is to create a new CApplication object (because that's how the Singleton mechanism works - check back to part 2 if you don't remember). We then pass argc and argv straight into the CApplication's Run() function. When it's done, we delete the CApplication object. So that's the whole of the 'application itself' done.
Then we do a last call to IMMObject::CollectRemainingObjects. This is where one of the major advantages of having the CApplication object comes into play. When CollectRemainingObjects() is called, all IMMObject-derived objects will be deleted; but after that, if there are any CMMPointers still around, they'll try calling Release() on their pointers - which will cause an access violation. In the end, we see that we can't call CollectRemainingObjects while there are any IMMObjects alive (and assigned). This means that keeping global CMMPointers is unsafe - they don't get killed till after the main() function is done - so instead, we can keep them in the CApplication object, and they get destroyed when the CApplication object is destroyed. Thus, when we reach CollectRemainingObjects we can release all still-allocated objects to avoid memory leaks completely, without worrying that anything is still latched onto them. When the CApplication object has been shut down, nothing should still be running, no CMMPointers should still be alive.
The CApplication object only needs to provide a Run() function; a constructor and destructor are optional (because for the most part, we'll only be adding CMMPointers to the CApplication object, and they have their own constructors), so I'm not going to show you the class definition here. Just remember that it derives from Singleton<CApplication>. Instead, let's skip straight to the Run() function:
void CApplication::Run(int argc, char *argv[]) { //open logfiles if(!CLog::Get().Init())return; //create a couple of singletons new CSettingsManager(); new CKernel(); //parse the 'settings.eng' file CSettingsManager::GetSingleton().ParseFile("settings.eng"); //parse command-line arguments //skip the first argument, which is always the program name if(argc>1) for(int i=1;i<argc;i++) CSettingsManager::GetSingleton().ParseSetting(std::string(argv[i])); //set up the profiler with an output handler CProfileLogHandler profileLogHandler; CProfileSample::outputHandler=&profileLogHandler; //main game loop CKernel::GetSingleton().Execute(); //clean up singletons delete CKernel::GetSingletonPtr(); delete CSettingsManager::GetSingletonPtr(); }Fairly self-explanatory. This is where many of the systems we've built up over the past articles tie together.
First, the logfiles. We want to have these available to us throughout the startup process, so that if something goes wrong and the game can't start at all, the logfiles are around for the user to find out why.
As soon as possible, we create the singletons - creating the CSettingsManager first is probably a good idea because the kernel may have some settings that should be in place before it gets constructed.
Next, we parse the 'settings.eng' file. This is totally optional, and the name is arbitrary, but you're probably going to need to load in a configuration file at some point, and now is as good a time as any. It's particularly useful when testing - you can set the screen mode so that you don't have to wait for the mode to change each time (and if you're on a multiple-monitor system, mess up your window layout ;-) ).
Then, the command-line arguments. We do these after settings.eng so that the command-line can 'override' the stored settings.
We set the profiler up to output to the logs (using our already-setup ProfileLogHandler). It's far from being the best output mechanism - ideally, we should be able to see stats on-screen while the game is running - but that's something we'll do later.
Then we start the main game loop itself (with CKernel::Execute()). Because we've not registered any tasks, this will return almost immediately.
Lastly we clean up our singletons.
If you build the project now, you should find that there are no unresolved dependencies, so it builds ok - running it will have the program start up and then exit. If you want to see for certain that it's running ok, add a log message in there (before CKernel::Execute(), probably). Let your mouth fall open in wonder and amazement; this is the blank slate of an engine upon which we build...
The Task Pool
The 'task pool' is the term I use to refer to the group of tasks that the engine is running at any given time. There are certain tasks that run pretty much all of the time - 'system tasks' - such as the timer or input tasks. These system tasks are what we're going to look at now.
Timer
The global timer task will be responsible for working out how many seconds have passed since the last frame. We can use that number to scale things like physics code, so that things move at the same speed across different machines:
class CGlobalTimer : public ITask { public: AUTO_SIZE; static float dT; static unsigned long lastFrameIndex; static unsigned long thisFrameIndex; bool Start(); void Update(); void Stop(); }; bool CGlobalTimer::Start() { thisFrameIndex=SDL_GetTicks(); lastFrameIndex=thisFrameIndex; dT=0; return true; } void CGlobalTimer::Update() { lastFrameIndex=thisFrameIndex; thisFrameIndex=SDL_GetTicks(); dT=((float)(thisFrameIndex-lastFrameIndex))/1000.0f; } void CGlobalTimer::Stop() { }SDL_GetTicks() returns the number of milliseconds since SDL_Init() was called, which we store in thisFrameIndex. To work out the elapsed time for this frame, we subtract the previous frame's value from that value, and divide by 1000 (to convert from milliseconds to seconds). The result is stored in a public static variable for easy access (so technically we should make the CGlobalTimer a Singleton, to prevent anyone creating more than one of it, but I didn't because multiple inheritance is something I wanted to avoid, if possible).
Sound
The sound task will initialize and shutdown the sound system, as well as pausing all active sounds when the task is paused. When pausing, we need to store which channels are actually active so we know which ones to unpause - the game itself might have paused some channels for it's own ends, and we don't want to accidentally unpause them.
class CSoundTask : public ITask { public: bool Start(); void OnSuspend(); void Update(); void OnResume(); void Stop(); AUTO_SIZE; protected: CMMPointer<CMMDynamicBlob<bool> > isPaused; }; bool CSoundTask::Start() { if(FALSE==FSOUND_Init(44100, 32, 0))return false; return true; } void CSoundTask::OnSuspend() { //pause all channels, storing the pause state in the isPaused array //once the states are stored we can use FSOUND_ALL to pause all //channels the easy way int chCount=FSOUND_GetMaxChannels(); isPaused=new CMMDynamicBlob<bool>(chCount); for(int i=0;i<chCount;i++) { if(FSOUND_IsPlaying(i)) { isPaused->buffer[i]=true; }else{ isPaused->buffer[i]=false; } } FSOUND_SetPaused(FSOUND_ALL,TRUE); } void CSoundTask::Update() { //we don't need to do anything, FMOD does it all for us :) } void CSoundTask::OnResume() { //unpause all the flagged channels if(isPaused) { int chCount=FSOUND_GetMaxChannels(); for(int i=0;i<chCount;i++) { if(isPaused->buffer[i])FSOUND_SetPaused(i,FALSE); } isPaused=0; } } void CSoundTask::Stop() { FSOUND_Close(); }Input
The input task has to get SDL to update it's internal input information, and then it has to read that information out. Again, we use public static variables for easy access (so again, I should make this a Singleton, but I haven't).Something to note is that SDL_GetKeyState returns a pointer to SDL's internal array - so we shouldn't free it ourselves.
class CInputTask : public ITask { public: CInputTask(); virtual ~CInputTask(); bool Start(); void Update(); void Stop(); static unsigned char *keys; static CMMPointer<CMMDynamicBlob<unsigned char> > oldKeys; static int keyCount; static int dX,dY; static unsigned int buttons; static unsigned int oldButtons; static bool inline curKey(int index) { return (keys[index]!=0); } static bool inline oldKey(int index) { return ((*oldKeys)[index]!=0); } //some helper functions to make certain things easier static bool inline keyDown(int index) { return ( curKey(index))&&(!oldKey(index)); } static bool inline keyStillDown(int index) { return ( curKey(index))&&( oldKey(index)); } static bool inline keyUp(int index) { return (!curKey(index))&&( oldKey(index)); } static bool inline keyStillUp(int index) { return (!curKey(index))&&(!oldKey(index)); } static bool inline curMouse(int button) { return (buttons&SDL_BUTTON(button))!=0; } static bool inline oldMouse(int button) { return (oldButtons&SDL_BUTTON(button))!=0; } static bool inline mouseDown(int button) { return ( curMouse(button))&&(!oldMouse(button)); } static bool inline mouseStillDown(int button) { return ( curMouse(button))&&( oldMouse(button)); } static bool inline mouseUp(int button) { return (!curMouse(button))&&( oldMouse(button)); } static bool inline mouseStillUp(int button) { return (!curMouse(button))&&(!oldMouse(button)); } AUTO_SIZE; }; bool CInputTask::Start() { keys=SDL_GetKeyState(&keyCount); oldKeys=new CMMDynamicBlob<unsigned char>(keyCount); dX=dY=0; SDL_PumpEvents(); SDL_PumpEvents(); return true; } void CInputTask::Update() { SDL_PumpEvents(); oldButtons=buttons; buttons=SDL_GetRelativeMouseState(&dX,&dY); memcpy((unsigned char*)(*oldKeys),keys,sizeof(unsigned char)*keyCount); keys=SDL_GetKeyState(&keyCount); } void CInputTask::Stop() { keys=0; oldKeys=0; }What's with the oldKeys and oldButtons members? At any given time, if you check a key in the keys array, all you'll know is if the key is down; not if it's just been pressed, or if it's being held down, or if it's just been released, and so on. By comparing it to it's previous state, oldKeys, we can quickly see if it's going down, going up, or staying put. Same goes for the mouse buttons. That's what all those inline functions are for you could write a separate 'input event' task which watches for those sorts of conditions and translates them into 'events' in a queue - a little more useful for things like text entry (because otherwise you just have to check every key, every frame).
Renderer
It's time we got something significant on screen. The VideoUpdate task will be responsible for starting up and shutting down the video system, along with swapping the screen buffers (because we're working with double buffers). It's also the first part of the engine to use the settings mechanism - we're going to have the screen mode (width, height, and BPP) registered as settings.
class CVideoUpdate : public ITask { public: CVideoUpdate(); virtual ~CVideoUpdate(); AUTO_SIZE; static int scrWidth, scrHeight, scrBPP; static CMMPointer<Dator<int> > screenWidth, screenHeight, screenBPP; bool Start(); void Update(); void Stop(); }; bool CVideoUpdate::Start() { assert(screenWidth && screenHeight && screenBPP); if(-1==SDL_InitSubSystem(SDL_INIT_VIDEO)) { CLog::Get().Write(LOG_CLIENT,IDS_GENERIC_SUB_INIT_FAIL, "Video",SDL_GetError()); return false; } SDL_GL_SetAttribute( SDL_GL_ALPHA_SIZE, 8 ); SDL_GL_SetAttribute( SDL_GL_RED_SIZE, 8 ); SDL_GL_SetAttribute( SDL_GL_GREEN_SIZE, 8 ); SDL_GL_SetAttribute( SDL_GL_BLUE_SIZE, 8 ); SDL_GL_SetAttribute( SDL_GL_DEPTH_SIZE, 16 ); SDL_GL_SetAttribute( SDL_GL_DOUBLEBUFFER, 1 ); int flags = SDL_OPENGL | SDL_ANYFORMAT | SDL_FULLSCREEN; if(!SDL_SetVideoMode(scrWidth, scrHeight, scrBPP, flags)) { CLog::Get().Write(LOG_CLIENT, IDS_BAD_DISPLAYMODE, scrWidth, scrHeight, scrBPP, SDL_GetError()); return false; } //hide the mouse cursor SDL_ShowCursor(SDL_DISABLE); return true; } void CVideoUpdate::Update() { SDL_GL_SwapBuffers(); } void CVideoUpdate::Stop() { SDL_QuitSubSystem(SDL_INIT_VIDEO); }There. We also need to head back to the CSettingsManager, and add the following to CreateStandardSettings:
SETTING(int, CVideoUpdate::screenWidth, CVideoUpdate::scrWidth, "screenX"); SETTING(int, CVideoUpdate::screenHeight, CVideoUpdate::scrHeight, "screenY"); SETTING(int, CVideoUpdate::screenBPP, CVideoUpdate::scrBPP, "screenBPP");
Also, we add to DestroyStandardSettings:
CVideoUpdate::screenWidth = 0; CVideoUpdate::screenHeight = 0; CVideoUpdate::screenBPP = 0;
The parameters for the SETTING macro are, in case you'd forgotten, the type, dator, variable to bind the dator to, and name for the setting within the manager. Finally, it's worth noting the static definitions of scrWidth/scrHeight/scrBPP:
int CVideoUpdate::scrWidth=800; int CVideoUpdate::scrHeight=600; int CVideoUpdate::scrBPP=16;
If no setting is given for screenX/screenY/screenBPP in the settings file or on the command line, no assignments will be made to the relevant dators and so scrWidth/scrHeight/scrBPP will not be changed from their initial values. Thus, set them up with your default values.
Pulling it all together (again)
Now that we've got these tasks, let's head back to our game-specific CApplication object, and try them out. We'll need to decide on priorities for each one - the priorities determine the order in which the tasks are run - and we'll need a simple task of our own to add to the mix, otherwise the app won't really do anything at all (including exit).
Here's the order of execution - the 'pipeline:'
CGlobalTimer (priority: 10) CInputTask (priority: 20) CSoundTask (priority: 50) COurTestTask (priority: 100) CVideoUpdate (priority: 10000)
You can see that the tasks are fairly well spaced out; an app could add at least 9 tasks between the system ones, and the gap between the sound task and the video update is large enough for anything. Before we set up the pipeline itself, here's the test task:
class COurTestTask : public ITask { public: bool Start() {return true;} void Update() { glClear(GL_COLOR_BUFFER_BIT); if(CInputTask::mouseDown(SDL_BUTTON_LEFT)) CKernel::GetSingleton().KillAllTasks(); } void Stop(){}; AUTO_SIZE; };Very simple. It'll just cause all tasks to shutdown when you press the left mouse button, and clears the screen in the meantime. So, now we go back to CApplication::Run, and just before calling CKernel::Execute(), we create tasks and put them into the pipeline:
//it's probably a good idea to have all the system tasks together. //The priority system means the tasks can officially be created in //any order (though bear in mind that the CVideoUpdate task must be //added to the kernel before any task using GL functions in its //Start() method, because SDL_VIDEO will not have been initialized). //We'll create the system tasks first and then our game-specific ones //afterwards. It also ensures that when we get to game-specific tasks, //things like FSOUND_Init() have been called. CMMPointer<CGlobalTimer> globalTimer = new CGlobalTimer(); globalTimer->priority=10; //the CMMPointer<ITask> expression here is used to typecast //the pointer from CGlobalTimer* to ITask* CKernel::GetSingleton().AddTask(CMMPointer<ITask>(globalTimer)); CMMPointer<CInputTask> inputTask = new CInputTask(); inputTask->priority=20; CKernel::GetSingleton().AddTask(CMMPointer<ITask>(inputTask)); CMMPointer<CSoundTask> soundTask = new CSoundTask(); soundTask->priority=50; CKernel::GetSingleton().AddTask(CMMPointer<ITask>(soundTask)); videoTask = new CVideoUpdate(); videoTask->priority=10000; CKernel::GetSingleton().AddTask(CMMPointer<ITask>(videoTask)); //game-specific tasks: CMMPointer<COurTestTask> tt=new COurTestTask(); tt->priority=100; CKernel::GetSingleton().AddTask(CMMPointer<ITask>(tt));
Build and test that - you should get a blank screen, which exits when you click the mouse. In the word of many millions of people, 'Yes!'
The Code
The code for this article contains a bit more than what we've seen here - I've written a very (and I mean very) basic implementation of Pong. Move your paddle using the mouse; click (or just lose the game :P ) to exit. See what you can do with it - if you need ideas, I'd suggest getting the ball to come off the paddle at different angles depending on where you hit it, or maybe adding sound. The relevant code is in CPongTask, in main.cpp; I recognize that you can't do much impressive stuff without texturing, which is coming soon. Still, consider it an exercise in pure gameplay - if you can make that Pong game fun, without using any fancy graphics and effects further than shaded polygons, then major kudos; I'll be truly impressed. Maybe it should be a lounge mini-contest.
There are also some updates to code from previous articles, based on feedback I've had from people (mostly minor bugfixes). The most important change is probably in the memory manager - previously, I'd overlooked stack objects, which could have lead to *serious* problems:
CSomeIMMObjectDerivedClass obj; CMMPointer<CSomeIMMObjectDerivedClass> ptr=&obj; ptr=0; IMMObject::CollectGarbage(); //heap fault - obj has a reference count of zero, but we //shouldn't call delete() because we didn't allocate it using new()!
The memory manager has now been updated to handle them. I believe I've commented the code; the best documentation, however, is the discussion that lead to the discovery (and later fixing) of the problem, in the discussion thread for Enginuity part 2. Indeed, all the discussion threads have been rich sources of information and ideas for me (and others too, they tell me :) ).
Conclusion
Well, that's a basic (and I mean basic) engine finished. You could stop reading now, and just work with what we've built up together; it's a pretty stable base for any project. Maybe you'd care to rewrite it with DirectX or change some other fundamental feature; I hope my articles have given you enough understanding of the way the engine works to allow you to do that.
However, as much as you can stop reading, doesn't mean I'm going to stop writing. After all, I haven't met my specification yet - there's still the networking system to be implemented, along with the beginnings of a 3D graphics system... but more importantly, there's no games built on this engine yet! It's no good if games can't actually *use* it.
I'd just like to take this opportunity to thank the people who've supported me so far - eldee, my loyal proofreader; Oluseyi, my seems-loyal-enough-but-I-reckon-has-a-hidden-agenda-yeah-buddy-I'm-onto-you proofreader; and all the many people who gave me their comments and feedback, through email, the forums, and IRC. I'll try not to let you down as I progress. :)
So, I'm far from finished. Next article I plan to cover textures and fonts, as well as the mysterious Interpolators and Triggers systems. In the meantime, I recommend you visit the 'Discuss this article' link to point out all my mistakes and pick apart my methods; or, of course, you can still email me (rfine at tbrf dot net).
ming1016@gmail.com 关注Enginuity, Part V
Serialization, Triggers/Interpolators, and the Build Stamp or, I Can't Think Of A Witty Subtitle For This One
Get the source code for this article here.
I'm back, after a bit of a break to build up the codebase and the example game a bit. And to catch up on Real Life . ;-)
Many thanks to all the people who've written in recently, urging me to continue with the series; I assured you all that I was not stopping, and this should be proof of that. From the list of topics I've got to cover, I estimate that our first demo game - CY - should arrive around article 8. So I've still got more to go, and I plan to continue after that, so...
This article I'm going to show off a few tricks I came up with for Enginuity which aren't really something that every engine *needs* - they come under the heading of 'nifty features,' but you can get along without them. However, nifty as they are, I thought I'd show them to you. Four things: Serialization, the Triggers and Interpolators systems, and the build stamp.
Serialization
You'll like this one, you really will. I know that when I thought of it up in Edinborough, I was still grinning about it when the train reached Birmingham. (Doesn't being easily amused just *rock* on long journeys? :-) )
'Serialization' is the general term for the processes of 'flattening' your data, for byte-stream uses such as storage in a file or transmission over a network, and then 'inflating' it again at the other end. Typical situations are messages for networked games, saved games, loading levels... hell, any custom resource that is loaded from or saved to a file needs a serialization mechanism (and the 'standard' resources, like bitmaps and sounds, need them too, but as far as Enginuity is concerned the libraries we use provide their own mechanisms).
There are a large number of problems. Firstly, what data do you need to save out or load in? Your entire heap? The positions and velocities of every single object and entity, down to individual particles in the particle systems, for everything that is loaded into memory? Probably not. Then, there's the problem of actually structuring that data - after all, saving pointers straight to file is no good when you load them back in again, so you can't just do a dump of your objects. You need to determine a 'flat' structure for them - something I call a 'serialization structure.' Dators are a bit slow, and only really need to be used when a variable is going to be set by name.
If you've ever worked with Quake BSP files, you'll have come across these 'serialization structures.' When a BSP file is actually in memory there's quite a bit more information than when on disk - a fair number of pointers between objects, for example - and that information is constructed when the level is loaded. The serialization structures - which, in Quake's case, are quite literally C structures - dictate how each piece of information in the BSP file is laid out - how a block of 32 bytes is divided into 4 longs, 6 ints, and 4 chars.
So, while the first major problem - what to serialize - will depend on the situation, the second problem - how to do it - is something we can address with a strong framework. See, creating an entire C structure for each piece of information you want to serialize is not just messy, it's also inflexible - it's fixed at compile-time. I'm going to present to you a mechanism that uses function calls, rather than C structures, to establish serialization structures. That way you can easily control which functions get called through something as basic as if() blocks.
There's also one of the original design requirements to be taken into account - the requirement that the engine be cross-platform. Well, while it's possible to write tools to load data files, swap the bytes around, and save them out again for different architectures, it's also a pain - and something which, in a time-critical situation, can really bite. It stinks of 'primary platform, plus ports' rather than 'cross-platform development;' it also requires the development of tools for each individual project, because a generalised program wouldn't know about the actual structure of the data and thus wouldn't know which bytes need swapping. The technique we look at today is completely architecture-independent; it actually uses SDL_Net's buffer read/write macros to serialize all data into network byte order (big-endian). If you don't like depending on SDL_Net to do that, it's very simple to replace the macros with your own equivalents.
Firstly, let's look at a typical scenario. Say we have a ball object, CBall, and we want serialization functions on it. All it needs, to begin with, is position information.
CBall::Flatten(ostream &out) { out << position.x; out << position.y; } CBall::Inflate(istream &in) { in >> position.x; in >> position.y; }Now, that works pretty well in simple situations, but it's potentially very problematic. Firstly, it's inflexible in that it depends on having STL streams to serialise to/from - that may well not be the case. Secondly, it's either going to be using a text-based format for storing the data - which is inefficient - or a binary format which will not necessarily be cross-platform. Thirdly, there's no real scope for error in there - things like buffer overflow aren't really tracked. Lastly, the existence of two seperate serialization functions means that you have to keep them both synchronized - some may argue that it's just one of the habits of a good programmer, like pairing calls to 'new' with calls to 'delete,' but I still maintain that everyone makes mistakes, and the smaller the chances of them happening, the better.
It's 'hard-coded.' I don't just mean that if you change it you need to recompile - soft code often needs that too - but changing it requires changes in more than one place, and, depending on the change, could potentially require changes in many places - adding support for a new type of source or target, for example. The solution we're going to look at is more 'soft-coded' - the serialization structure is defined in one place and one place only, the system is pretty extensible, and - best of all - you can accurately calculate other things about the serialization structure, like how much space it *actually* needs (rather than just hoping that a sizeof() is big enough).
What we have is simple - our objects support a 'serialize' method, which takes a single pointer to a 'target' object as its parameter. That 'serialize' function is all an object needs to provide to work with the system. That may sound a little familiar to those of you who have some MFC experience, but even MFC requires that you define the structure twice - once for loading, once for saving. The serialize function calls methods on the 'target' object, and those calls define the structure: the target object's methods are things like IOChar, IOFloat, IOLong, IOString, and so on. See where I'm going with this yet?
With each of those calls, a reference to the variable in question is passed. So, IOChar(char &value), IOFloat(float &value), etc. Then - and here comes the cruncher - we derive different objects from the base 'target' interface - CSerialFlattener, CSerialInflator, CSerialSizer, and so on. They can then write the values to a buffer, read the values in from a buffer, or simply add to a running total of bytes written. The object itself never touches the actual read/write buffer - assuming there is one - and any change to the structure is felt across all operations on the structure. You could write 'serializer objects' to do pretty much anything - count the number of different types of variable used, generate code for a C-structure based on the serialization structure, whatever. The first three have suited me just fine so far. By forcing buffer read/writes to happen through a sort of 'visitor' object, we can do things like ensure that strings are always written out in a consistant way (I opt for size followed by unterminated string, but you could just as easily make all strings null-terminated), or check for buffer overflow/underflow (because underflow can be just as bad a sign as overflow).
In fact, we can even take advantage of another nice feature of C++ - overloading. Instead of seperate IOChar/IOFloat type names for things, we just have a single IO() function overloaded for different types. Though, don't go too far - this might seem like one of the places where templates would work well, but remember that each type will probably need to be handled differently, making templates useless (because they're for applying the *same* operations to different types). Using overloading, though, is much nicer; it means that we don't have to check that the type of the variable matches the function, because the compiler will pick the correct function for us.
The end result is a system which is a little bit like C++ iostreams, but does away with the concept of 'input' or 'output.'
Let's pull out some code. Firstly, our base ISerializer interface:
class ISerializer { public: virtual void IO(unsigned char &value)=0; virtual void IO(unsigned long &value)=0; virtual void IO(std::string &str)=0; };If that looks a bit short, it's just because I've not needed any more types than unsigned char, unsigned long, and std::string just yet. It's pretty easy to add new types, as you'll understand soon. Let's start looking at the Serializers themselves with the simplest one, the CSerialSizer:
class CSerialSizer : public ISerializer { protected: unsigned long length; public: CSerialSizer() { length=0; } void IO(unsigned char &value) { ++length; } void IO(unsigned long &value) { length+=4; } void IO(std::string &str) { IO(length); length+=str.length(); } unsigned long getLength() { return length; } };Pretty simple. A char adds 1 byte to the size; an unsigned long adds 4 bytes. Why do I use literals instead of sizeof() expressions? Because - while in this case, the sizes of char and unsigned long are pretty much guaranteed - we want to be writing out the size of the data when serialized, rather than the size of the data when in memory. If I were to add an 'unsigned int' overload, would the size of it be 16 bits or 32 bits? Because we're trying to keep this cross-platform, we can't really guarantee either; and given that we're trying to keep the data *itself* cross-platform too, we have to pick one and stick with it (I'd probably opt for 32 bits). Thus, the size of an 'unsigned int' when serialized - 4 bytes - might not correspond to the size of an 'unsigned int' when in memory - 2 bytes. For consistency I decided to write things the same way for the non-ambiguous types too; you're perfectly free to use sizeof() if you want, just bear in mind that sizeof() isn't always the right answer.
Incidentally, the 'length' variable is protected, rather than private, for a simple reason: you will quite probably introduce your own basic data structures in projects, which should be kept specific to that project. So, to minimize polluting the Enginuity codebase itself with overloads for your custom data types, you only need to overload in once place - ISerializer - and then you can derive your own CExtendedSerialSizer (or whatever) which implements those new overloads; the Enginuity serializer classes themselves will (aside from ISerializer itself) be unchanged. (If you wanted to be *really* neat and avoid polluting the Enginuity codebase all together, you could create another interface class - IExtendedSerializer - which has ISerializer as a virtual public base class. Then, you derive your CExtendedSerialSomething from both CSerialSomething *and* IExtendedSerializer; the end result should be an extended class which has overloads from both base classes in it, and you can still use IExtendedSerializer as an interface to all your extended serializer objects).
When you use CSerialSizer (or your own extension of it), it'll probably be to allocate a buffer for use with a CSerialSaver.
class CSerialSaver : public ISerializer { protected: unsigned char *buffer; bool bHasOverflowed; unsigned long length; unsigned long bytesUsed; public: CSerialSaver(unsigned char *buf, unsigned long size) { buffer=buf; length=size; bytesUsed=0; bHasOverflowed=false; } void IO(unsigned char &value) { if(bHasOverflowed)return; //stop writing when overflowed if(bytesUsed+1>length){bHasOverflowed=true; return; } *buffer=value; ++buffer; ++bytesUsed; } void IO(unsigned long &value) { if(bHasOverflowed)return; //stop writing when overflowed if(bytesUsed+4>length){bHasOverflowed=true; return; } SDLNet_Write32(value,buffer); buffer+=4; bytesUsed+=4; } void IO(std::string &str) { unsigned long l=str.length(); IO(l); if(bHasOverflowed)return; if(bytesUsed+l>length){bHasOverflowed=true; return; } memcpy(buffer,str.c_str(),l); buffer+=l; bytesUsed+=l; } bool hasOverflowed() { return bHasOverflowed; } //should be equal to 0 when we're done long getFlow() { return length-bytesUsed; } };There. The constructor takes a pointer to the buffer you want it to fill, along with the size of that buffer (so it can track when it's overflowing). Then, we have three overloads. The first two are very similar: they check that the overflow flag hasn't been set (and if it has, they bail out). Then, they check that the write wouldn't *cause* the buffer to overflow (and it if would, flag the overflow and bail out). Then they perform the write itself; the unsigned char overload simply copies through the byte, while the unsigned long overload uses an SDL_Net macro to make sure the value is written out in network byte order (it's a simple macro, so if you don't like depending on SDL_Net, it's easy to replace). Then, each increments the buffer pointer (the current write position) and the number of bytes used up.
The last overload - std::string - is pretty similar, but it actually calls one of the other overloads to write out the size of the string before the string itself. You can create such 'composite serial members' in this way; an overload for a vector class, for example, would probably just be implemented using three calls to IO(float). (If you're using my IExtendedSerializer suggestion, that's one of the beautiful bits - you can actually implement the overload in the base IExtendedSerializer class, and when you later derive from it with CSerialSaver/CSerialLoader the IO calls from the overload will be mapped to the correct virtual functions. That is, your extended overload calls IO(long), which successfully goes through to the CSerialSomething that you extended).
The last two functions, hasOverflowed() and getFlow(), are for error detection. The first of the two is pretty simple - it tells you whether the overflow flag has been set (there was an attempt to write more data than the buffer could hold). The second is for detecting underflow; this isn't such a serious error as overflow, but it still might be indicative of something not having worked correctly - especially if you're using a buffer with the size given by a CSerialSizer and the object you're serialising hasn't changed. The serialisation structure should be exactly the same in both cases, so if it hasn't filled the buffer perfectly, something's screwy. If you don't use a CSerialSizer, and just pass a buffer that you think is large enough, then you can use the flow to work out how much of the buffer was actually used (to save you writing out the extra padding at the end).
Now, the CSerialLoader:
class CSerialLoader : public ISerializer { protected: unsigned char *buffer; bool bHasOverflowed; unsigned long length; unsigned long bytesUsed; public: CSerialLoader(unsigned char *buf, unsigned long size) { buffer=buf; length=size; bytesUsed=0; bHasOverflowed=false; } void IO(unsigned char &value) { if(bHasOverflowed)return; //stop writing when overflowed if(bytesUsed+1>length){bHasOverflowed=true; return; } value=*buffer; ++buffer; ++bytesUsed; } void IO(unsigned long &value) { if(bHasOverflowed)return; //stop writing when overflowed if(bytesUsed+4>length){bHasOverflowed=true; return; } value=SDLNet_Read32(buffer); buffer+=4; bytesUsed+=4; } void IO(std::string &str) { unsigned long l; IO(l); if(bHasOverflowed)return; if(bytesUsed+l>length){bHasOverflowed=true; return; } char *szBuf=new char[l+1]; szBuf[l]=0; memcpy(szBuf,buffer,l); str=szBuf; delete[] szBuf; buffer+=l; bytesUsed+=l; } bool hasOverflowed() { return bHasOverflowed; } //should be equal to 0 when we're done long getFlow() { return length-bytesUsed; } };Pretty damn similar to the CSerialSaver, I think you'll agree. The constructor is exactly the same; the first two IO overloads simply flip the assignments over, so that the buffer is now copied to the value rather than the other way around. The third overload looks a little more complex, but it's actually still pretty simple - it reads back in the size as an unsigned long, and then allocates a temporary buffer to hold the string; reads the string, converts it to a std::string, and delete the temporary buffer. Once again, at the end, we have hasOverflowed() and getFlow(), doing exactly the same thing as before. Underflow is more of a problem here, as it means the whole buffer wasn't read in - if you thought you'd handed it a complete resource, evidently the serialization structure of the data is different to that of the object, so the data is either corrupt or you're trying to feed it into the wrong object.
Let's take a look at a sample serialization function on an object, then. This is taken from the high-scores table code in the upcoming demo game, CY. Here are the relevant parts of the class definition:
class CHighScoresTable : public Singleton<CHighScoresTable> { public: CHighScoresTable(); virtual ~CHighScoresTable(); void Serialize(ISerializer *s); bool Load(); bool Save(); struct hs { std::string name; unsigned long score; }scores[10]; inline int getScoreCount() const { return 10; } };While there are seperate Load/Save functions in this object, they don't actually touch the serialization structure - all they do is create the Serializer objects and work with the highscor.dat file, as you'll see.
The constructor initializes all the values in the table to the default highscores. If the highscor.dat file can't be opened, the scores will reset to defaults, and then get written out in a new file. So, to reset the high scores you can just delete the highscores file.
CHighScoresTable::CHighScoresTable() { for(int i=0;i<10;i++) { scores[i].name="Nobody"; scores[i].score=100-i*10; } }Here's the serialization function itself. For each entry in the table, it just gives the name (a std::string) and score (an unsigned long).
void CHighScoresTable::Serialize(ISerializer *s) { for(int i=0;i<10;i++) { s->IO(scores[i].name); s->IO(scores[i].score); } }This is how loading the table is actually done. The function has no relation to the serialization structure; changes to the Serialize() function will not affect it. All it does is open the file, read in the contents, and hand it to the Serialize function (in a CSerialLoader) to be actually loaded.
bool CHighScoresTable::Load() { unsigned long size=0; FILE *fp=fopen("highscor.dat","rb"); if(!fp)return false; fseek(fp,0,SEEK_END); size=ftell(fp); fseek(fp,0,SEEK_SET); unsigned char *buffer=new unsigned char[size]; fread(buffer,1,size,fp); fclose(fp); CSerialLoader sl(buffer, size); Serialize(&sl); assert(sl.getFlow()==0); delete[] buffer; return true; }And here's the complimetary save function. Again, it opens the file; it uses both a CSerialSizer *and* a CSerialSaver to get the size of the data to write out, though in fact this could be made more efficient by writing a CFileSaver which writes directly to the file rather than to a buffer. The same goes for the Load function.
bool CHighScoresTable::Save() { FILE *fp=fopen("highscor.dat", "wb"); if(!fp)return false; CSerialSizer ss; Serialize(&ss); unsigned long size=ss.getLength(); unsigned char *buffer=new unsigned char[size]; CSerialSaver sv(buffer,size); Serialize(&sv); assert(sv.getFlow()==0); fwrite(buffer,size,1,fp); fclose(fp); delete[] buffer; return true; }I think you'll agree that's pretty simple, especially when you're dealing with a large number of different objects - if you've got, say, a set of 100 objects of varying classes, all implementing some kind of ISerializable interface, then you can have a single save/load function pair to loop through all of them and call Serialize() functions on them.
Now, the method isn't without its caveats. For one, it requires that you plan your serialization structures with a bit more care; for example, if the number of entries in a serialization structure is going to be variable, you *have* to record that number at the beginning, rather than simply reading/writing till all the bytestream is used up. Such structures, though, are what I'd call 'deterministic;' you always have the information to read/write without needing any knowledge at all of the underlying byte-stream. After all, if you had serializers which sent data to and from sockets directly, you wouldn't necessarily *have* an end-of-file to test against.
Triggers/Interpolators
It's time to look at a couple more 'utility' objects - 'utility' in that they're internal engine objects that you don't really use on their own. However, that doesn't stop me from having found them to be some of the most useful objects in the engine. They're fairly similar in design and operation - they just behave a little differently - which is why I'm presenting them together.
Interpolators
Interpolators are particularly useful objects when it comes to polish and effects.
Quite simply, Interpolators take an input value - most often the clock - and use it to interpolate an output value. The output is set by reference, so the interpolator can directly change it with no problems. Want a fade-out effect? Simply set up an interpolator with your global alpha value as the output. It's also very simple to set up different types of interpolation - plain linear interpolation may do for many things, but smoother methods - quadratic and cubic - will be shown here as well.
class IInterpolator : public IMMObject { protected: float ⌖ static std::list< CMMPointer<IInterpolator> > interpolators; public: IInterpolator(float &t); virtual ~IInterpolator(); bool bFreeze; virtual void Update(float dt)=0; void Kill(); friend class CInterpolatorUpdater; AUTO_SIZE; };We're going to assume that all interpolators work with float values (I can't think of many situations where they wouldn't be - though if you *really* needed to, you could set it up with templates). So, we have a float reference to our 'target' value - our output. We also have a list of CMMPointers to IInterpolators.
This next one took me a while to get right. Interpolators are sort of self-referencing objects. When you create one, it will add itself to the 'interpolators' list using a CMMPointer - which means that even if you release all your pointers to the interpolator, it'll still be held in existence by that pointer in the list. Why is that useful? It means you can create an interpolator, and drop it out of scope, without it dying. It's going to need to be alive to function. When it comes down to it, you don't even need to keep a local copy of the interpolator - you can just do 'new CLinearInterpolator(...);' as a standalone statement, and this will handle the rest. Of course, if you do that you lose control of the interpolator - you can't pause it or kill it unless it pauses or kills itself. A possible extension to this system, then, would be a way to give an interpolator an ID - a string or unique number - which you give the interpolator when it is created, and can then be used to retrieve a pointer to the interpolator later on, so you can (for example) kill it.
Now we get to the public functions. There's the constructor and destructor - the constructor taking the initial value for 'target.' bFreeze is a boolean flag which you can set to 'freeze' the interpolator - it will not be updated while it is frozen.
Update() is the function derived classes must implement. It's that function which 'powers' the interpolator - does the calculation and assigns to the output value. It takes dT - 'delta time' - as a parameter, because 95% of our interpolators will be time-based so, unless you're going for micro-optimization and consider the time taken to pass the argument too much, there's no point having them all fetch the time themselves. If an interpolator doesn't need it, it ignores it.
Kill() is a simple function to remove the interpolator from it's own list. If you call Kill() on an interpolator, and then drop all references to it, it really *will* be destroyed. Technically, you can call Kill() on it and still keep references to it, but it won't be updated any more (unless you call IInterpolator::Update() on it yourself each frame).
Lastly, it marks CInterpolatorUpdater (we'll meet it in a minute) as a friend class, and then uses the expected AUTO_SIZE macro to fulfill abstract functions on IMMObject, from which it derives.
Here's the (brief) implementation of the non-abstract functions:
IInterpolator::IInterpolator(float &t) : target(t) { interpolators.push_back(this); bFreeze=false; } IInterpolator::~IInterpolator() { } void IInterpolator::Kill() { std::list< CMMPointer<IInterpolator> >::iterator it= (std::find(interpolators.begin(),interpolators.end(),this)); if(it!=interpolators.end()) (*it)=0; }The constructor sets up the target reference, and adds itself to the list of pointers. It also has the interpolator start in a non-frozen state by default. The destructor does nothing (it's only there to make sure derived destructors works properly). Lastly, Kill() finds the interpolator in the list and (assuming it can find it) sets its pointer to zero, releasing it.
So... we've got all these interpolators knocking about with Update() functions on them, all in a list - sounds fairly easy to do. We'll use a task for it:
class CInterpolatorUpdater : public ITask { public: bool Start(); void Update(); void Stop(); AUTO_SIZE; };That's about as minimial a task as you can get.
bool CInterpolatorUpdater::Start() { return true; } void CInterpolatorUpdater::Stop() { IInterpolator::interpolators.clear(); } void CInterpolatorUpdater::Update() { PROFILE("Interpolator task"); std::list< CMMPointer<IInterpolator> >::iterator it, ite=IInterpolator::interpolators.end(), itT; for(it=IInterpolator::interpolators.begin(); it!=ite; it++) { if((*it).isValid()) { (*it)->Update(CGlobalTimer::dT); }else{ //remove invalid entries from the list, just to keep things fast itT=it; --it; IInterpolator::interpolators.erase(itT); } } }Start() does nothing (it doesn't need to do anything). Stop() kills the list of pointers - thus releasing all interpolators when the task is shut down (otherwise they'd still be in scope when CollectRemaningObjects gets called, giving 'unreleased object' reports in the logs). Update() simply loops through the list of interpolators; for each interpolator, it tests that the pointer is actually valid, and if it is, calls Update() on it. If not, it removes that entry from the list - no point iterating over dead entries, and as more and more interpolators are created and destroyed, those dead entries would build up.
So, that's our basic interpolator system. Let's see some objects we'll actually use!
class ITimebasedInterpolator : public IInterpolator { protected: float elapsedTime, totalTime; virtual void Calculate()=0; public: void Reset(); void Update(float dt); ITimebasedInterpolator(float &targ, float time); AUTO_SIZE; };This is the base class for interpolators which interpolate from start to finish across a fixed time period. Note that there are plenty of interpolators that *use* time but are not considered time-based - a sine-wave interpolator would be an example, an interpolator which oscillates its target value at a given phase, amplitude and frequency for an indefinite period of time. This base class implements Update() - which updates the elapsed time and checks to see if the total time has been exceeded (in which case the interpolator expires, Kill()ing itself). There's also a Reset() function, which sets elapsedTime back to zero (to 'restart' the interpolator). However, it adds an abstract function of its own - Calculate - which the classes below implement to work out the output value in their own specific ways:
class CLinearTimeInterpolator : public ITimebasedInterpolator { protected: float startVal, endVal; void Calculate(); public: CLinearTimeInterpolator(float &targ, float time, float sV, float eV); AUTO_SIZE; }; class CQuadraticTimeInterpolator : public ITimebasedInterpolator { protected: float startVal, midVal, endVal; void Calculate(); public: CQuadraticTimeInterpolator(float &targ, float time, float sV, float mV, float eV); AUTO_SIZE; }; class CCubicTimeInterpolator : public ITimebasedInterpolator { protected: float startVal, midVal1, midVal2, endVal; void Calculate(); public: CCubicTimeInterpolator(float &targ, float time, float sV, float mV1, float mV2, float eV); AUTO_SIZE; };How do these interpolators work? To answer that, we're going to need to do a little math.
Firstly, we can treat the time as a value between 0 and 1 - 0 means no time has elapsed, and 1 means all time (totalTime) has elapsed. Call that value 'b.' In a linear interpolator, we want a 'b' value of 0 to produce the start value, and a 'b' value of 1 to produce the end value. A 'b' value of 0.5 should produce a value half-way between the start and end.
We could say that the start value is equal to 'startValue * 1 + endValue * 0' and that the end value is equal to 'startValue * 0 + endValue * 1.' In fact, for any value through the interpolator, it'll be 'startValue * someNumber + endValue * someOtherNumber.' someNumber and someOtherNumber will always add up to 1 - that is, 'one whole value.' They're blending weights.
When 'b' is 0 someOtherNumber is 0, and when 'b' is 1 someOtherNumber is 1 - it doesn't take too much effort to suppose that someOtherNumber=b. Given that someOtherNumber + someNumber = 1, someNumber must = 1 - b. We'll call that 'a.'
So, in a linear interpolator, the output is 'a * startVal + b * endVal.' And if you look at the code:
void CLinearTimeInterpolator::Calculate() { //calculate b, keeping it clamped to the range [0,1] float b=clamp(elapsedTime/totalTime,0,1); target = startVal*(1-b) + endVal*b; }Exactly what we said. How about the next interpolators, though? Are they quite as simple?
Nearly. We've established that '(a+b)=1'. That means that '(a+b)^2=1' (because 1^2=1). If you multiply out (a+b)^2, you get 'a^2 + 2ab + b^2' - three values. If we add to our startValue and endValue a 'middleValue,' we can do 'a^2 * startValue + 2ab * middleValue + b^2 * endValue.' The placement of the middleValue with respect to the start and end values will affect the 'steepness' of things at each end - for a sudden fade-in and then gradual fade-out, you could use a quadratic interpolator with the middleValue near the startValue. Fun fact: quadratics were how Quake 3 did its curvy surfaces ('bezier patches').
void CQuadraticTimeInterpolator::Calculate() { float b=clamp(elapsedTime/totalTime,0,1), a=1-b; target = startVal*a*a + midVal*2*a*b + endVal*b*b; }The theory extends. If '(a+b)^2=1' produces an expression with 3 terms - 'coefficients' - then it's not a tremendous leap of the imagination to say that '(a+b)^3' would produce 4 terms. That's right - 'a^3 + 3ba^2 + 3ab^2 + b^3' - so we can plug four values into our interpolator. The expression (a+b)^3 is 'a plus b cubed,' thus this is a 'cubic' interpolator.
It's possible to have an interpolator which accepts any number of values. Given 'n' values, you just expand '(a+b)^(n-1)' to get your coefficients. It follows a nice pattern - for term 'r' out of a total of 'n' terms, the coefficient is something like 'nCr * a^r * b^(n-r).' Google for the 'binomal theorem' if you want to know more; more terms mean more calculation time, though, and cubic interpolation is usually good enough for me.
void CCubicTimeInterpolator::Calculate() { float b=clamp(elapsedTime/totalTime,0,1), a=1-b; target = startVal*a*a*a + midVal1*3*a*a*b + midVal2*3*a*b*b + endVal*b*b*b; }The only thing that remains:
void ITimebasedInterpolator::Update(float dt) { if(bFreeze)return; elapsedTime+=dt; Calculate(); if(elapsedTime>totalTime) { Kill(); } }Triggers
Triggers, like interpolators, are small objects that you can chuck around pretty liberally. They have a task updating them in the same way as the interpolators, but rather than working with changing an output, instead they monitor an input. Again, the input is set by reference; the idea is that you set a trigger up with a variable to 'watch' and a functor to call when a certain condition is met, and then let it get on with things; "don't call us, we'll call you."
class ITrigger : public IMMObject { public: ITrigger(Functor *h, bool fo); virtual ~ITrigger(); void Kill(); protected: CMMPointer<Functor> handler; bool bFireOnce; virtual bool Test()=0; static std::list< CMMPointer<ITrigger> > triggerList; friend class CTriggerTask; private: void Tick(); };You should spot some similarities to the IInterpolator base class; there's the friend declaration, and the list of memory-managed ITrigger pointers. There's also that Kill() function, to remove a trigger before it expires (or if, indeed, it's set not to expire). Notice that the base class doesn't have a reference to an input variable - that's because unlike the Interpolators, we're going to allow Triggers to work with any type (not just float).
ITrigger::ITrigger(Functor *h, bool fo) { handler=h; bFireOnce=fo; triggerList.push_back(this); } ITrigger::~ITrigger() { } void ITrigger::Kill() { std::list<CMMPointer<ITrigger> >::iterator it= std::find(triggerList.begin(), triggerList.end(), this); if(it!=triggerList.end()) (*it)=0; } void ITrigger::Tick() { if(Test()) { (*handler)(); if(bFireOnce) { Kill(); } } }Again, all fairly familiar stuff. The constructor handles the self-referencing list stuff again, and the Kill() function has just changed interpolatorList to triggerList. The Tick() function is the equivalent of the Update() function in the interpolators, and as such is called every frame. The Test() function performs the actual test - in most derived classes it'll be a one-line function, as you'll see. If it returns true - that is, the test condition is satisfied - then the handler is called, and the trigger is destroyed (if it's been set to only fire once).
class CTriggerTask : public ITask { public: bool Start(); void Update(); void Stop(); AUTO_SIZE; }; bool CTriggerTask::Start() { return true; } void CTriggerTask::Stop() { ITrigger::triggerList.clear(); } void CTriggerTask::Update() { PROFILE("Trigger task"); std::list< CMMPointer<ITrigger> >::iterator it, ite=ITrigger::triggerList.end(), itT; for(it=ITrigger::triggerList.begin(); it!=ite; it++) { if((*it).isValid()) { (*it)->Tick(); }else{ itT=it; --it; ITrigger::triggerList.erase(itT); } } }Identically minimalistic. (This one actually *was* a copy-and-paste job).
Now, onto some derived classes. I use the names 'subject' and 'object' for the input and the thing it's tested against ('subject-predicate-object', where predicate is the test itself):
template<class T> class CEqualsTrigger : public ITrigger { protected: T &subject; T object; public: CEqualsTrigger(T& s, T o, Functor *h, bool fo=true) : ITrigger(h,fo), subject(s) { object=o; } bool Test(){return (subject==object);} AUTO_SIZE; };You can now see why most of the Test() functions will be one-line jobs. Any type you want to use with the triggers system is going to need operators for whatever test you want to perform, of course - you won't be able to create a CEqualsTrigger<SomeClass> if SomeClass doesn't provide an == operator (you'll get a compiler error). You'll also need assignment operators - for the 'object' parameter, at the very least.
template<class T> class CNotEqualsTrigger : public ITrigger { protected: T &subject; T object; public: CNotEqualsTrigger(T& s, T o, Functor *h, bool fo=true) : ITrigger(h,fo), subject(s) { object=o; } bool Test(){return !(subject==object);} AUTO_SIZE; }; template<class T> class CLessTrigger : public ITrigger { protected: T &subject; T object; public: CLessTrigger(T& s, T o, Functor *h, bool fo=true) : ITrigger(h,fo), subject(s) { object=o; } bool Test(){return (subject<object);} AUTO_SIZE; }; template<class T> class CGreaterTrigger : public ITrigger { protected: T &subject; T object; public: CGreaterTrigger(T& s, T o, Functor *h, bool fo=true) : ITrigger(h,fo), subject(s) { object=o; } bool Test(){return (subject>object);} AUTO_SIZE; };You get the idea. It becomes drastically easy to write trigger classes - so easy, in fact, that I provided you a macro for doing it (commented out, in triggers.h). TRIGGER_CLASS(classname, test) will define a trigger class - there are a couple of examples there for you. Of course, for those of you who consider macros to be the devil, I invite you to exorcise it from the file, liberally redecorate with holy water, and write the classes out in full. Your choice. There are also a bunch of 'key' triggers, designed to work with the input system, which call functors when keys are pressed/released (making key binding incredibly simple).
The Buildstamp
This isn't so much part of the engine as it is a simple project trick that could be more suited to a Sweet Snippet or something like it, but I'm going to put it in anyway because (a) Enginuity uses it, and (b) it's nifty.
When you're working with a project over a fairly long period of time, having gone through several builds, with various betas and test releases having been distributed amongst team members and the odd friend looking to test their particular hardware configuration.. it can be difficult to keep track of them all, to find out which 'version' of an in-development project you're dealing with. It becomes particularly important when it comes to networked games - you need to ensure that all your clients have the same build of the game, otherwise messages could get interpreted differently across platforms, protocols could mismatch.. you'd generally get what appear to be a load of bugs which are actually just due to not having updated the build properly.
So, the most obvious way to do this is to track the date and time of the build. You *could* just request that everyone right-click their executables and check that the creation date is the same across all machines, but that's hardly efficient - quite aside from the fact that you're relying on the local file having the same creation date as the build, and with things like version-control systems knocking around there's no guaranteeing that'll be the case. No, the best option is to compile the date and time into the executable itself; you get the added advantage, there, of being able to access it in code. So, when your networked games connect to each other, they can compare 'buildstamps' - if they're not the same, the connection is not made; the two times could even be tested to see which is older, and the developer in question given a message telling them to update their build.
A global variable does the trick.
const std::string buildStamp = "ProjectName " __TIMESTAMP__;
__TIMESTAMP__ is, according to MSDN, a universally defined ANSI C macro, so it should be safe to use. If you can't get it to work, you may need to resort to the more advanced method I describe in a minute. I've got my projects set up with that line in a file all on its own - 'buildstamp.cpp.' An 'extern const std::string buildStamp' in engine.h makes the buildstamp value available throughout the engine.
The only problem we've not mentioned is, ironically, with the efficiency of the compiler. Buildstamp.cpp will get built once, but then with no changes it won't get built again - irritatingly, the compiler doesn't pick up that because the value of __TIMESTAMP__ has changed, the resulting object code would be different, but oh well. Add a custom build step in MSVC, or a custom makefile rule, to delete the buildstamp.obj file after the executable is built. That will force the compiler to regenerate the buildstamp.cpp file whenever you want to link and produce a new executable.
For the more elite amongst you, you may want to use a more advanced technique - it's not something I really thought I needed, but it gives you increased flexibility and could potentially be useful in a situation where multiple developers are working on a project. Basically, rather than writing the buildstamp.cpp file yourself, you write a short program to generate it - with the obvious advantage that such a program would have access to any information you care to give it access to, including the name of the user currently logged onto the computer ('built by "fine.richard" at...'), or even a file to keep track of the number of times the generator program has been run (a build number). As before, you hook this program in as a custom build step, and voila - each build will have a freshly-generated buildstamp.cpp file, containing any information you want. I'd recommend that you write such a program to use a config file specific to the project - you can store the project name and current build number in there, while reusing the generator program across projects.
Conclusion
We've covered a chunk more today; hopefully that'll keep you all going for a bit while I finish up with article 6. :-) The interpolator and triggers come in particularly handy for polished GUI sequences I've found - menus sliding smoothly in and out - and the buildstamp, while not essential just yet, will come in very handy when we write the networking system.
Incidentally, I just ported the demo game - 'CY' - across to Mac OS
X . The total time taken was about 4.5 hours, and most of that was spent accounting for differences between the compilers (like the fact that GCC has better adherence to standards than MSVC6, and thus didn't let me get away with much of my sloppy coding). There were only about 3 actual bugs that were exposed by the port, and one of them was simply that I'd not implemented user-level logging on Mac at that time (I have now, thanks to John McDowall - major kudos, man). Four and a half hours. Not bad, hmm? :-) The OSX port is an entrant into the uDevGame competition over at www.idevgames.com - Mac owners, go and help them out by playing the games and voting for your favourites! Judging by some of the entries last year - like the spectacular 'Kiki the Nanobot' - you won't be disappointed with the originality and sheer style of some of the entries.
I'm off for lunch. If you've got questions or comments, the discussion thread is a good place to start, otherwise I can be reached at rfine AT tbrf no-spam-monkeys DOT net. Next time, I think we'll be looking at the texturing system, but until then: have fun!
