These slides are based on the excellent book on Game Programming Patterns by Robert Nystrom.
25 Nis, 2024
These slides are based on the excellent book on Game Programming Patterns by Robert Nystrom.
The measure of a design is how easily it accommodates changes.
If two pieces of code are coupled, it means you can’t understand one without understanding the other.
You Aren’t Gonna Need It
There’s no easy answer here. Making your program more flexible so you can prototype faster will have some performance cost. Likewise, optimizing your code will make it less flexible.
It’s easier to make a fun game fast than it is to make a fast game fun.
Prototyping — slapping together code that’s just barely functional enough to answer a design question
Big caveat
Boss: “Hey, we’ve got this idea that we want to try out. Just a prototype, so don’t feel you need to do it right. How quickly can you slap something together?”
Dev: “Well, if I cut lots of corners, don’t test it, don’t document it, and it has tons of bugs, I can give you some temp code in a few days.”
Boss: “Great!”
A few days later…
Boss: “Hey, that prototype is great. Can you just spend a few hours cleaning it up a bit now and we’ll call it the real thing?”
We have a few forces in play:
Simplicity is the perfect way to balance architecture and performance
Simple code is easier to learn and modify
Simple code is usually faster to run as there is less overhead
However, simple code is not easy to come up with
“I would have written a shorter letter, but I did not have the time.”
Blaise Pascal
“Perfection is achieved, not when there is nothing more to add, but when there is nothing left to take away.”
Antoine de Saint-Exupery
Encapsulate a request as an object, thereby letting users parameterize clients with different requests, queue or log requests, and support undoable operations.
A command is a reified method call.
reify -> make something real, thingify, objectify
wrapping a function call in an object
void InputHandler::handleInput() { if (isPressed(BUTTON_X)) jump(); else if (isPressed(BUTTON_Y)) fireGun(); else if (isPressed(BUTTON_A)) swapWeapon(); else if (isPressed(BUTTON_B)) lurchIneffectively(); }
class Command { public: virtual ~Command() {} virtual void execute() = 0; };
jump()
and fireGun()
into something that we can swap outThen we create subclasses for each of the different game actions:
class JumpCommand : public Command { public: virtual void execute() { jump(); } }; class FireCommand : public Command { public: virtual void execute() { fireGun(); } }; // You get the idea...
In our input handler, we store a pointer to a command for each button:
class InputHandler { public: void handleInput(); // Methods to bind commands... private: Command* buttonX_; Command* buttonY_; Command* buttonA_; Command* buttonB_; };
Now the input handling just delegates to those:
void InputHandler::handleInput() { if (isPressed(BUTTON_X)) buttonX_->execute(); else if (isPressed(BUTTON_Y)) buttonY_->execute(); else if (isPressed(BUTTON_A)) buttonA_->execute(); else if (isPressed(BUTTON_B)) buttonB_->execute(); }
Where each input used to directly call a function, now there’s a layer of indirection:
class Command { public: virtual ~Command() {} virtual void execute(GameActor& actor) = 0; };
GameActor
is our “game object” class that represents a character in the game worldclass JumpCommand : public Command { public: virtual void execute(GameActor& actor) { actor.jump(); } };
handleInput
to return the appropriate command object, rather than execute the actionCommand* InputHandler::handleInput() { if (isPressed(BUTTON_X)) return buttonX_; if (isPressed(BUTTON_Y)) return buttonY_; if (isPressed(BUTTON_A)) return buttonA_; if (isPressed(BUTTON_B)) return buttonB_; // Nothing pressed, so do nothing. return NULL; }
Command* command = inputHandler.handleInput(); if (command) { command->execute(actor); }
class MoveUnitCommand : public Command { public: MoveUnitCommand(Unit* unit, int x, int y) : unit_(unit), x_(x), y_(y) {} virtual void execute() { unit_->moveTo(x_, y_); } private: Unit* unit_; int x_, y_; };
Command* handleInput() { Unit* unit = getSelectedUnit(); if (isPressed(BUTTON_UP)) { // Move the unit up one. int destY = unit->y() - 1; return new MoveUnitCommand(unit, unit->x(), destY); } if (isPressed(BUTTON_DOWN)) { // Move the unit down one. int destY = unit->y() + 1; return new MoveUnitCommand(unit, unit->x(), destY); } // Other moves... return NULL; }
class Command { public: virtual ~Command() {} virtual void execute() = 0; virtual void undo() = 0; };
An undo()
method reverses the game state changed by the corresponding execute()
method.
class MoveUnitCommand : public Command { public: MoveUnitCommand(Unit* unit, int x, int y) : unit_(unit), xBefore_(0), yBefore_(0), x_(x), y_(y) {} virtual void execute() { // Remember the unit's position before the move, so we can restore it. xBefore_ = unit_->x(); yBefore_ = unit_->y(); unit_->moveTo(x_, y_); } virtual void undo() { unit_->moveTo(xBefore_, yBefore_); } private: Unit* unit_; int xBefore_, yBefore_; int x_, y_; };
The fog lifts, revealing a majestic old growth forest. Ancient hemlocks, countless in number, tower over you forming a cathedral of greenery. The stained glass canopy of leaves fragments the sunlight into golden shafts of mist. Between giant trunks, you can make out the massive forest receding into the distance.
A sprawling woodland can be described with just a few sentences
But actually implementing it in a realtime game is another story.
When you’ve got an entire forest of individual trees filling the screen, all that a graphics programmer sees is the millions of polygons they’ll have to somehow shovel onto the GPU every sixtieth of a second.
100 trees on screen
That must travel from the cpu to the gpu every second
Each tree has a bunch of bits associated with it:
If you were to sketch it out in code, you’d have something like this:
class Tree { private: Mesh mesh_; // large data Texture bark_; // large data Texture leaves_; // large data Vector position_; double height_; double thickness_; Color barkTint_; Color leafTint_; };
We can model that explicitly by splitting the object in half.
class TreeModel { private: Mesh mesh_; Texture bark_; Texture leaves_; };
The game only needs a single one of these, since there’s no reason to have the same meshes and textures in memory a thousand times.
class Tree { private: TreeModel* model_; Vector position_; double height_; double thickness_; Color barkTint_; Color leafTint_; };
We have to send the shared data just once.
Then, we send every tree instance’s unique data
Finally, we tell the GPU, “Use that one model to render each of these instances.”
Today’s graphics APIs and cards support exactly that.
Starting in Direct3D version 9, Microsoft included support for geometry instancing. This method improves the potential runtime performance of rendering instanced geometry by explicitly allowing multiple copies of a mesh to be rendered sequentially by specifying the differentiating parameters for each in a separate stream. The same functionality is available in Vulkan core, and the OpenGL core in versions 3.1 and up but may be accessed in some earlier implementations using the EXT_draw_instanced extension.
A common approach is to use an enum for terrain types:
enum Terrain { TERRAIN_GRASS, TERRAIN_HILL, TERRAIN_RIVER // Other terrains... };
Then the world maintains a huge grid of those:
class World { private: Terrain tiles_[WIDTH][HEIGHT]; };
To actually get the useful data about a tile, we do something like:
int World::getMovementCost(int x, int y) { switch (tiles_[x][y]) { case TERRAIN_GRASS: return 1; case TERRAIN_HILL: return 3; case TERRAIN_RIVER: return 2; // Other terrains... } } bool World::isWater(int x, int y) { switch (tiles_[x][y]) { case TERRAIN_GRASS: return false; case TERRAIN_HILL: return false; case TERRAIN_RIVER: return true; // Other terrains... } }
A terrain class can do the job for us:
class Terrain { public: Terrain(int movementCost, bool isWater, Texture texture) : movementCost_(movementCost), isWater_(isWater), texture_(texture) {} int getMovementCost() const { return movementCost_; } bool isWater() const { return isWater_; } const Texture& getTexture() const { return texture_; } private: int movementCost_; bool isWater_; Texture texture_; };
Notice that all of the methods here are const. Since the same object is used in multiple contexts, if you were to modify it, the changes would appear in multiple places simultaneously. That’s probably not what you want. Because of this, Flyweight objects are almost always immutable.
class World { private: Terrain* tiles_[WIDTH][HEIGHT]; // pointers not objects // Other stuff... };
Since the terrain instances are used in multiple places, their lifetimes would be a little more complex to manage if you were to dynamically allocate them. Instead, we’ll just store them directly in the world:
class World { public: World() : grassTerrain_(1, false, GRASS_TEXTURE), hillTerrain_(3, false, HILL_TEXTURE), riverTerrain_(2, true, RIVER_TEXTURE) {} private: Terrain grassTerrain_; Terrain hillTerrain_; Terrain riverTerrain_; // Other stuff... };
Then we can use those to paint the ground like this:
void World::generateTerrain() { // Fill the ground with grass. for (int x = 0; x < WIDTH; x++) { for (int y = 0; y < HEIGHT; y++) { // Sprinkle some hills. if (random(10) == 0) { tiles_[x][y] = &hillTerrain_; } else { tiles_[x][y] = &grassTerrain_; } } } // Lay a river. int x = random(WIDTH); for (int y = 0; y < HEIGHT; y++) { tiles_[x][y] = &riverTerrain_; } }
Now instead of methods on World for accessing the terrain properties, we can expose the Terrain object directly:
const Terrain& World::getTile(int x, int y) const { return *tiles_[x][y]; }
This way, World is no longer coupled to all sorts of details of terrains. If you want some property of the tile, you can get it right from that object:
int cost = world.getTile(2, 3).getMovementCost();