Today, most programs only use a fraction of the total memory available on a device, so the memory budget often isn't stressed. However, consider for a moment that you are a game developer creating an open world game that takes place in a forest where there could be hundreds of thousands of trees. If we're not careful we could easily run over the available memory! However, if the trees share resources such as their textures, it can make a drastic difference! In development, having many objects share common resources with each other can be done using the Flyweight Pattern.
Assumptions
- Basic programming knowledge
- Knowledge of basic <a href="https://www.devmaking.com/learn/design-patterns/design-pattern-modeling/" target="_blank" style="color:inherit;">Modeling Notation</a>
What is the Flyweight Pattern?
A flyweight is an object that attempts to reduce memory by sharing itself with other's that have similar attributes. The pattern should be considered when there are potentially millions of object's in use that all have a repeating (intrinsic) state: a piece of data that doesn't change between them.
<div style="width:100%; margin:auto;text-align:center;"><img src="https://www.devmaking.com/img/topics/designpatterns/FlyweightPattern_01.png" alt="Flyweight pattern UML diagram." style="max-width:95%;"> </div>
Key components:
- Intrinsic State: a shared resource that does not change.
- Flyweight: an object that contains an intrinsic state.
- Extrinsic State: state that varies with each instance of an object.
- Flyweight Factory: factories often accompany a flyweight to "cache" the intrinsic state.
A good example of this is creating a forest in a video game; the same model is used for every tree, and only the tree's coordinates differ. If each tree object had it's own, identical model, there would be thousands of duplicate objects in memory:
<div style="width:100%; margin:auto;text-align:center;"><img src="https://www.devmaking.com/img/topics/designpatterns/FlyweightPattern_02.png" alt="one-to-one references" style="max-width:95%;"> </div>
Instead, if the tree object's shared the model, only one instance of the model would ever be in memory (in theory), and only extra space would be needed for references to the model , which are comparatively miniscule!
<div style="width:100%; margin:auto;text-align:center;"><img src="https://www.devmaking.com/img/topics/designpatterns/FlyweightPattern_03.png" alt="consolidated references diagram." style="max-width:95%;"> </div>
Applying the Flyweight Pattern
To continue with the topic of video games, let's say we're creating an old school 2D platformer game. Usually there will be a single sprite (image/texture) or collection of sprites that make up the environment called tilemaps. These are useful because they make the environment modular so the file sizes can be smaller and levels don't have to be drawn entirely by hand.
<div style="width:100%; margin:auto;text-align:center;"><img src="https://www.devmaking.com/img/topics/designpatterns/FlyweightPattern_04.png" alt="spritemap UML diagram" style="max-width:95%;"> </div>
In addition to keeping the file size low, we'd like to keep the memory usage lower as well. We can achieve this by only loading each texture once, and referencing the texture each time we'd like to draw a new tile to the screen. To begin, let's define our tile class:
/* pseudo-code */
/* Generating an interface let's us later create
other classes that might not have shared resources */
interface ISprite {
// Draws a new instance of the texture at x,y coordinates:
void draw(Graphics g, int x, int y);
}
// Flyweight:
class Sprite implements ISprite {
// Repeating state:
Texture texture;
// Some default scales for the draw() method.
final int SCALE_X = 1;
final int SCALE_Y = 1;
Sprite(Texture texture) {
this.texture = texture;
}
// Unique state:
void draw(Graphics g, int x, int y,) {
gLib.drawSquare(x, y, SCALE_X, SCALE_Y);
gLib.setTexture(texture);
}
}
Now that we have our sprite class defined, we can move on to create the flyweight factory. Typically, the factory will be implemented in a way that saves a new instance of a texture in a "cache", and if it isn't yet present, it will still save the texture to it.
An implementation note about Flyweights is that they are often paried with Singletons to allow them to be called from anywhere within an application that would need to use the Flyweight Factory, although this isn't necessarily a requirement.
// Flyweight factory:
class TextureManager {
// Maintains the textures used already:
static Map<String, Texture> cachedTextures = new Map<String, texture>();
// Retrieves a texture:
static Texture getTexture(String name) {
Texture tex = cachedTextures.get(name);
// If it doesn't exist, we create it:
if(tex is null) {
tex = new Texture(name);
cachedTextures.put(name, tex);
}
return tex;
}
}
Notice that the cache and getTextrue
method are static
: this allows us to maintain the flyweight throughout the program without needing to continuously create new instances and repopulate them. With the flyweight and factory created, we can show the design pattern in action:
static void main(String[] args) {
// For our sake, let's say the two texture names are the path's they exist at
// (relative to the project root)
const String grassTextureSrc = "Grass.png";
const String pathTextureSrc = "Path.png";
// Initializing some textures:
Texture grass = TextureManager.getTexture(grassTextureSrc);
Texture path = TextureManager.getTexture(pathTextureSrc);
// Getting two textures from the flyweight factory:
Sprite grassSprite = new Sprite(grass);
Sprite pathSprite = new Sprite(path);
// Getting a texture that already exists in the factory: memory is saved!
Sprite anotherGrassSprite = new Sprite(grass);
// Some arbitrary graphics library:
Graphics g = new ConcreteGraphicsLibrary();
// Drawing our tiles onto a screen:
grassSprite.draw(g, 0,0);
pathSprite.draw(g, 1,0);
anotherGrassSprite.draw(g, 2,0);
}
Refactoring The Texture Manager
Modern video games are nothing short of an engineering miracle in some senses; they're able to produce amazing visuals and gameplay elements by squeezing out every performance boost they can get. There is a reality that comes with this: expertly tailored systems require opinionated design stances that come with their own set of compromises.
The Problem:
There are a few pain-points with the current design that we should address:
- While hashmaps have amortized O(1) search time, hashing a string is relatively slow compared to an integer.
- The hash of a string is an integer, so the hash of an integer is (usually) itself!
- Using the pathnames to reference textures outside the initialization phase can get messy. Using an enum to reference textures would be cleaner, and enums can be resolved to an integer!
- Some languages offer an enum map collection, which is a hashmap specialized for enum types, and comes with significant performance advantages.
- This means having an enum value for every texture in the game/application. This is where you'll need to have consideration for design; if you're running a AAA production with 1000+ textures, you might need a heavy-duty approach; however, if you're making a home-grown game with a range of 100-200 textures, you can still justify this design!
> Much of using an enum over a string is based around a need to reference textures at runtime. If you design your application to load all textures into memory and populate references when the application is loading, you might be able to get away with the first design; although you could still gain convenience from using enums!
The Solution:
We can increase the speed of our texture management system by utilizing enums in place of strings for the texture names. However, in our first example, we were using the string name as the source for the texture so that we could initialize it if it wasn't already in the system; we can take an opinionated stance and force the texture to already exist when the getTexture()
method is being called.
<div style="width:100%; margin:auto;text-align:center;"><img src="https://www.devmaking.com/img/topics/designpatterns/FlyweightPattern_05.png" alt="spritemap refactor UML diagram" style="max-width:95%;"> </div>
To work with this, we'll want to create an add()
method, and refactor our getTexture()
method to use Enums. Before we do that, we need to define a TextureName enumeration:
enum TextureName {
Grass,
StonePath,
/* ... */
/* Add more texture names when needed */
}
With our texture name, we can refactor our TextureManager:
class TextureManager {
// Maintains the textures used already:
static Map<TextureName, Texture> cachedTextures = new Map<TextureName, texture>();
// Retrieves a texture:
static Texture getTexture(TextureName name) {
Texture tex = cachedTextures.get(name);
// If it doesn't exist, we want to throw a fit!
// Dev Tip:
// Throw errors for client issues,
// but *assert* that we're using our designs correctly.
assert(tex != null);
return tex;
}
// Inititalizes a texture:
static void add(TextureName name, String filePath) {
// Create a texture and add it to our map:
Texture tex = new Texture(filePath);
cachedTextures.put(name, tex);
}
}
Now that we've cleaned up the texture manager, we can add one more nice-to-haves to our Sprite class: a setTexture()
method for swapping out the texture at runtime! We'll also add in an alternate constructor for creating using a TextureName instead of a reference if it is needed.
// Flyweight:
class Sprite implements ISprite {
// Repeating state:
Texture texture;
// Some default scales for the draw() method.
final int SCALE_X = 1;
final int SCALE_Y = 1;
Sprite(Texture texture) {
this.texture = texture;
}
Sprite(TextureName name) {
this.texture = TextureManager.getTexture(name);
}
void setTexture(TextureName name) {
this.texture = TextureManager.getTexture(name);
}
// Unique state:
void draw(Graphics g, int x, int y,) {
gLib.drawSquare(x, y, SCALE_X, SCALE_Y);
gLib.setTexture(texture);
}
}
Refactored Demo:
static void main(String[] args) {
// Static references to the texture source:
const String grassTextureSrc = "Grass.png";
const String pathTextureSrc = "Path.png";
// Initializing the textures in the manager:
TextureManager.add(TextureName.Grass, grassTextureSrc);
TextureManager.add(TextureName.StonePath, pathTextureSrc);
// Creating two sprites using the Enum option:
Sprite grassSprite = new Sprite(TextureName.Grass);
Sprite pathSprite = new Sprite(TextureName.StonePath);
// Creating a sprite using a texture reference:
Texture grass = TextureManager.getTexture(TextureName.Grass);
Sprite anotherSprite = new Sprite(grass);
// Some arbitrary graphics library:
Graphics g = new ConcreteGraphicsLibrary();
// Drawing our tiles onto a screen:
grassSprite.draw(g, 0,0);
pathSprite.draw(g, 1,0);
anotherSprite.draw(g, 2,0);
// Swapping out the texture on the last sprite for a stone path:
anotherSprite.setTexture(TextureName.StonePath);
anotherSprite.draw(g, 2, 0);
}
Recommended Resources
- <a target="_blank" href="https://www.amazon.com/gp/product/0596007124/ref=as_li_tl?ie=UTF8&camp=1789&creative=9325&creativeASIN=0596007124&linkCode=as2&tag=devmaking-20&linkId=bc32087110669f75d93b216df79816f0" style="color:#fff;border-radius:3px;background-color:#888;padding:1px 5px">Head First Design Patterns: A Brain-Friendly Guide</a> : An excellent primer for learning design patterns in a pragmatic way.
- <a target="_blank" href="https://www.amazon.com/gp/product/0201633612/ref=as_li_tl?ie=UTF8&camp=1789&creative=9325&creativeASIN=0201633612&linkCode=as2&tag=devmaking-20&linkId=ae25d94c4ea49870eb3115e0e4d2de90" style="color:#fff;border-radius:3px;background-color:#888;padding:1px 5px">Design Patterns: Elements of Reusable Object-Oriented Software</a> : The reference guide made famous by Gang of Four, still widely used today.
- <a href="https://www.draw.io" target="_blank" style="color:#fff;border-radius:3px;background-color:#888;padding:1px 5px">Draw.io</a>: A free, open-source tool for designing diagrams with built-in support for UML diagrams to make your own!