Skip to content

TUTORIAL Decals

Javidx9 edited this page Sep 12, 2022 · 4 revisions

OK, this is where the fun begins! Decals in the purest sense are the same as sprites. In fact, you can't have an olc::Decal without an olc::Sprite.

Every surface that can be drawn to in the olc::PixelGameEngine is an olc::Sprite - even the screen! Sprites live in RAM and are accessed and manipulated by the CPU. When you draw to a sprite, the changes are persistent. For example, it's common practice to call Clear() each frame in OnUserUpdate(), as this removes the contents of the "screen sprite" from the previous frame. On the one hand, this makes olc::Sprite very flexible, but on the other, they are processor intensive to work with. For most applications sprites are fine (as you've seen in the tutorial so far we are still performing in the 1000's of frames per second), however as you want to do more things with sprites, such as change their transparency, or draw them rotated or scaled, or even simply the sprite is very large, very quickly things are going to grind to a halt. Your CPU can only do so much.

Pretty much all computers in the last 10 years have hardware dedicated to manipulating graphics - we know this as a GPU. The olc::PixelGameEngine fundamentally uses the GPU to draw to the display, but the GPU can be used to do so much more! This is where decals come in. A decal is a sprite that lives on the GPU. And this is an important distinction; in order to make full use of the GPU, the GPU needs its own "private" access to the image data. Sending all the sprites to the GPU every frame would significantly affect performance, and so instead we keep a copy of the sprite, the decal, directly in the GPU memory.

Since the decal image is no longer in RAM, it can't be manipulated by the CPU. The GPU will draw the decal on top of whatever was drawn by the CPU first. This also means that decals are not persistent across frames - they don't need an equivalent Clear(). Each frame, you will need to redraw all of the decals. If we need to change the decal, then we need to re-download the sprite it represents to the GPU.

Now the GPU has full control over the drawing, it can use all the parallel processing resources it has at its disposal, which means it can draw the image very, very, very quickly! So quick in fact, that we have enough graphics processing time to also perform additional transformations. We can rotate, scale and transform the image in various ways, we can change its colour, its transparency, we can even distort the boundary of the image to change its shape from a rectangle to, well, whatever quadrilateral we like!

Depending upon what you are implementing, you may choose never to draw sprites at all, and always use decals - fine. You may never use decals - also fine. As the designer it's down to you to decide what is the best approach for your application. Fortunately, using decals is just as simple as using sprites, as we will see in this part of the tutorial.

Let's add an explosion effect each time the ball hits a tile! When a tile is "hit", I'm going to create a cluster of "fragments" that explode from the hit location. These fragments are purely a visual effect, they don't have an effect on the gameplay, or the environment (though they could...)

Since I anticipate there being many fragments, and I want them to change in transparency, rotate, change colour, and I don't want them permanently drawn into the scene, they make an ideal candidate to be draw using a decal. I only need one sprite for a fragment, and its this really tiny 8x8 one:

Note that it's black and white. This is a design choice, as I'm going to let the GPU colour it for me. You can of course have any colours you want.

To load the decal, I'm going to add pointers for a sprite and a decal to represent this fragment to the main class

std::unique_ptr<olc::Sprite> sprFragment;
std::unique_ptr<olc::Decal> decFragment;

and then in OnUserCreate(), I'll load the fragment sprite, and use it to initialise the decal. This will take care of making sure the sprite is somewhere on the GPU. I can still happily use the sprite as normal, but if I change it, I will need to call the decal's "Update()" function, to make sure the GPU resident image is up to date.

// Load Fragment Sprite
sprFragment = std::make_unique<olc::Sprite>("./gfx/tut_fragment.png");

// Create decal of fragment
decFragment = std::make_unique<olc::Decal>(sprFragment.get());

And that's all there is to it - we've made a decal. The next part is to establish a simple particle system to handle the explosion fragments. To my main class, I'm going to add a struct that represents an individual fragment, and I'll store all the fragments in a std::list.

struct sFragment
{
	olc::vf2d pos;
	olc::vf2d vel;
	float fAngle;
	float fTime;
	olc::Pixel colour;
};

std::list<sFragment> listFragments;

So for each fragment, I have its position in world space, its velocity, an angle that represents how it should be rotated, a track of how long the fragment has existed, and the colour the fragment should be. Tracking the lifetime of the fragment is important, because as we generate fragments we need to remember to remove them later, so when a fragment is created we'll set the fTime value to 3 seconds.

In our physics update code in OnUserUpdate(), I'll update the entire list of fragments:

// Actually update ball position with modified direction
vBallPos += vBallDir * fBallSpeed * fElapsedTime;

// Update fragments
for (auto& f : listFragments)
{
	f.vel += olc::vf2d(0.0f, 20.0f) * fElapsedTime;
	f.pos += f.vel * fElapsedTime;
	f.fAngle += 5.0f * fElapsedTime;
	f.fTime -= fElapsedTime;
	f.colour.a = (f.fTime / 3.0f) * 255;
}

// Remove dead fragments
listFragments.erase(
	std::remove_if(listFragments.begin(), listFragments.end(), [](const sFragment& f) { return f.fTime < 0.0f; }),
	listFragments.end()
);

Lets analyse whats happening here. First we iterate through every fragment in the list. Then we apply some simple physics. We update the velocity vector with gravity, in this case its an acceleration +20.0f in the Y-Axis (which is down the screen remember?). Then we update the position with the new velocity. The fragment rotates as it flies, so I'll update the angle. The lifetime of the fragment is decreased, and finally I set the transparency of the fragment based upon its lifetime - fTime/3.0 being a percentage multiplied by 255 - so at the beginning, the fragment has full opacity, and fades out as it gets older.

Next I use the erase-remove idiom to remove all fragments that have expired. The predicate of the std::remove_if function returns true if the fragment's time left < 0, thus leading to it being erased. This will stop our list of fragments growing, which in turn means we are not wasting time processing and drawing fragments which cannot be seen. We could go further, and check if the fragment's position is no longer in the visible world space, but for our needs the fragments are not going to be around for very long anyway.

We now need to add fragments to the list when a tile collision occurs. I've modified the "TestResolveCollisionPoint()" lambda function we created earlier:

auto TestResolveCollisionPoint = [&](const olc::vf2d& point, olc::vf2d& hitpos, int& id)
{
	olc::vi2d vTestPoint = vPotentialBallPos + vTileBallRadialDims * point;
	auto& tile = blocks[vTestPoint.y * 24 + vTestPoint.x];
	if (tile == 0)
	{
		// Do Nothing, no collision
		return false;
	}
	else
	{
		// Ball has collided with a tile
		bool bTileHit = tile < 10;
		if (bTileHit)
		{
			id = tile;
			hitpos = { float(vTestPoint.x), float(vTestPoint.y) };
			tile--;
		}
			
		// Collision response
		if (point.x == 0.0f) vBallDir.y *= -1.0f;				
		if (point.y == 0.0f) vBallDir.x *= -1.0f;
		return bTileHit;
	}
};

It now returns two additional values (via argument references) which indicate the location of the collision, and the colour ID of the tile. So now when we do our tests, if the overall result for a hit is true, we know where and which colour!

bool bHasHitTile = false;
olc::vf2d hitpos;
int hitid = 0;
bHasHitTile |= TestResolveCollisionPoint(olc::vf2d(0, -1), hitpos, hitid);
bHasHitTile |= TestResolveCollisionPoint(olc::vf2d(0, +1), hitpos, hitid);
bHasHitTile |= TestResolveCollisionPoint(olc::vf2d(-1, 0), hitpos, hitid);
bHasHitTile |= TestResolveCollisionPoint(olc::vf2d(+1, 0), hitpos, hitid);	

Therefore, we can now generate fragments. I'm going to go nuts and generate 100 fragments for each hit - this is probably far too many to be sensible, but it looks crazy enough to be interesting.

if (bHasHitTile)
{
	for (int i = 0; i < 100; i++)
	{
		sFragment f;
		f.pos = { hitpos.x + 0.5f, hitpos.y + 0.5f };
		float fAngle = float(rand()) / float(RAND_MAX) * 2.0f * 3.14159f;
		float fVelocity = float(rand()) / float(RAND_MAX) * 10.0f;
		f.vel = { fVelocity * cos(fAngle), fVelocity * sin(fAngle) };
		f.fAngle = fAngle;
		f.fTime = 3.0f;
		if (hitid == 1)	f.colour = olc::RED;
		if (hitid == 2)	f.colour = olc::GREEN;
		if (hitid == 3)	f.colour = olc::YELLOW;
		listFragments.push_back(f);
	}
}

In the event of a collision, loop through 100 new fragments, adding them to the list. For each new fragment, i'll set its position to the location of the collision, then generate a random velocity vector by choosing a random angle and a random scalar velocity. I'll also use that random angle to initialise the fAngle property of the fragment, because why not? And I set the lifetime of the fragment to be 3 seconds. Finally I can specify the fragment colour based upon the tile ID at the collision, and add the fragment to the list.

Now it's time to draw the decals. Decals are ALWAYS drawn after sprites, so in principle I could put the following code anywhere in our OnUserUpdate() function, but I like to keep things in order. Here I will use the DrawRotatedDecal() function to draw, err, well, a rotated decal.

// Draw Ball
FillCircle(vBallPos * vBlockSize, int(fBallRadius), olc::CYAN);

// Draw Fragments
for (auto& f : listFragments)
	DrawRotatedDecal(f.pos * vBlockSize, decFragment.get(), f.fAngle, { 4, 4 }, { 1, 1 }, f.colour);

The first argument is the location in screen space where the decal should be drawn. The second is the decal itself. The third is what angle (in radians) should the decal be rotated. The forth argument specifies the origin of rotation about where the decal should be rotated. The decal is 8x8 pixels, but I want it to rotate around its center, so I specify an offset of 4x4. The fifth argument is optional and it represents the scale. We could enlarge, shrink or mirror the decal here, but I'm keeping it "as is". The final argument is the tint. This allows us to colour the decal with a bias towards the colour specified. In our case, the colour is determined by the tile ID at the point of collision, and as the fragment is updated, we reduce its alpha component. This allows us to make the coloured fragment appear to fade out.

There are other decal drawing functions too:

DrawDecal() - Just draws the decal, again it can be scaled and tinted.

DrawPartialDecal() - Operates in the same way as DrawPartialSprite(), we can specify a source region of the decal to be drawn only, along with scaling and tinting.

DrawWarpedDecal() - This is a fun one, and allows us to stretch the decal by specifying where in screen space the four corners of it should be.

DrawStringDecal() - Included for completeness here, but it allows us to draw text very quickly to the screen, scaled and tinted too.

At this point, our game is still capable of playing itself, so we can test how the explosions look. You may want to tailor the number of fragments, and velocities to suit what you think looks best, but as the clip below shows, rendering many fragments barely affects our performance - and looks really cool!

Note the gif is much lower frame rate than the game, which makes it look a bit slow, however the game runs very smoothly.

And that's decals! Here is the final code. In the next section, we'll look at adding "player skill" to the game.

#define OLC_PGE_APPLICATION
#include "olcPixelGameEngine.h"

class BreakOut : public olc::PixelGameEngine
{
public:
	BreakOut()
	{
		sAppName = "TUTORIAL - BreakOut Clone";
	}

private:
	float fBatPos = 20.0f;
	float fBatWidth = 40.0f;
	float fBatSpeed = 25.0f;

	olc::vf2d vBallPos = { 0.0f, 0.0f };
	olc::vf2d vBallDir = { 0.0f, 0.0f };
	float fBallSpeed = 20.0f;	
	float fBallRadius = 5.0f;

	olc::vi2d vBlockSize = { 16,16 };
	std::unique_ptr<int[]> blocks;

	std::unique_ptr<olc::Sprite> sprTile;
	std::unique_ptr<olc::Sprite> sprFragment;
	std::unique_ptr<olc::Decal> decFragment;


	struct sFragment
	{
		olc::vf2d pos;
		olc::vf2d vel;
		float fAngle;
		float fTime;
		olc::Pixel colour;
	};

	std::list<sFragment> listFragments;

public:
	bool OnUserCreate() override
	{
		blocks = std::make_unique<int[]>(24 * 30);
		for (int y = 0; y < 30; y++)
		{
			for (int x = 0; x < 24; x++)
			{
				if (x == 0 || y == 0 || x == 23)
					blocks[y * 24 + x] = 10;
				else
					blocks[y * 24 + x] = 0;

				if (x > 2 && x <= 20 && y > 3 && y <= 5)
					blocks[y * 24 + x] = 1;
				if (x > 2 && x <= 20 && y > 5 && y <= 7)
					blocks[y * 24 + x] = 2;
				if (x > 2 && x <= 20 && y > 7 && y <= 9)
					blocks[y * 24 + x] = 3;
			}
		}

		// Load the sprite
		sprTile = std::make_unique<olc::Sprite>("./gfx/tut_tiles.png");

		// Load Fragment Sprite
		sprFragment = std::make_unique<olc::Sprite>("./gfx/tut_fragment.png");

		// Create decal of fragment
		decFragment = std::make_unique<olc::Decal>(sprFragment.get());

		// Start Ball
		float fAngle = float(rand()) / float(RAND_MAX) * 2.0f * 3.14159f;
		fAngle = -0.4f;
		vBallDir = { cos(fAngle), sin(fAngle) };
		vBallPos = { 12.5f, 15.5f };
		return true;
	}

	bool OnUserUpdate(float fElapsedTime) override
	{
		// A better collision detection
		// Calculate where ball should be, if no collision
		olc::vf2d vPotentialBallPos = vBallPos + vBallDir * fBallSpeed * fElapsedTime;

		// Test for hits 4 points around ball
		olc::vf2d vTileBallRadialDims = { fBallRadius / vBlockSize.x, fBallRadius / vBlockSize.y };

		auto TestResolveCollisionPoint = [&](const olc::vf2d& point, olc::vf2d& hitpos, int& id)
		{
			olc::vi2d vTestPoint = vPotentialBallPos + vTileBallRadialDims * point;

			auto& tile = blocks[vTestPoint.y * 24 + vTestPoint.x];
			if (tile == 0)
			{
				// Do Nothing, no collision
				return false;
			}
			else
			{
				// Ball has collided with a tile
				bool bTileHit = tile < 10;
				if (bTileHit)
				{
					id = tile;
					hitpos = { float(vTestPoint.x), float(vTestPoint.y) };
					tile--;
				}
				
				// Collision response
				if (point.x == 0.0f) vBallDir.y *= -1.0f;				
				if (point.y == 0.0f) vBallDir.x *= -1.0f;
				return bTileHit;
			}
		};

		bool bHasHitTile = false;
		olc::vf2d hitpos;
		int hitid = 0;
		bHasHitTile |= TestResolveCollisionPoint(olc::vf2d(0, -1), hitpos, hitid);
		bHasHitTile |= TestResolveCollisionPoint(olc::vf2d(0, +1), hitpos, hitid);
		bHasHitTile |= TestResolveCollisionPoint(olc::vf2d(-1, 0), hitpos, hitid);
		bHasHitTile |= TestResolveCollisionPoint(olc::vf2d(+1, 0), hitpos, hitid);		

		if (bHasHitTile)
		{
			for (int i = 0; i < 100; i++)
			{
				sFragment f;
				f.pos = { hitpos.x + 0.5f, hitpos.y + 0.5f };
				float fAngle = float(rand()) / float(RAND_MAX) * 2.0f * 3.14159f;
				float fVelocity = float(rand()) / float(RAND_MAX) * 10.0f;
				f.vel = { fVelocity * cos(fAngle), fVelocity * sin(fAngle) };
				f.fAngle = fAngle;
				f.fTime = 3.0f;
				if (hitid == 1)	f.colour = olc::RED;
				if (hitid == 2)	f.colour = olc::GREEN;
				if (hitid == 3)	f.colour = olc::YELLOW;
				listFragments.push_back(f);
			}
		}

		// Fake Floor
		if (vBallPos.y > 20.0f) vBallDir.y *= -1.0f;

		// Actually update ball position with modified direction
		vBallPos += vBallDir * fBallSpeed * fElapsedTime;


		// Update fragments
		for (auto& f : listFragments)
		{
			f.vel += olc::vf2d(0.0f, 20.0f) * fElapsedTime;
			f.pos += f.vel * fElapsedTime;
			f.fAngle += 5.0f * fElapsedTime;
			f.fTime -= fElapsedTime;
			f.colour.a = (f.fTime / 3.0f) * 255;
		}

		// Remove dead fragments
		listFragments.erase(
			std::remove_if(listFragments.begin(), listFragments.end(), [](const sFragment& f) { return f.fTime < 0.0f; }),
		listFragments.end());
		 
		// Draw Screen
		Clear(olc::DARK_BLUE);
		SetPixelMode(olc::Pixel::MASK); // Dont draw pixels which have any transparency
		for (int y = 0; y < 30; y++)
		{
			for (int x = 0; x < 24; x++)
			{
				switch (blocks[y * 24 + x])
				{
				case 0: // Do nothing
					break;
				case 10: // Draw Boundary
					DrawPartialSprite(olc::vi2d(x, y) * vBlockSize, sprTile.get(), olc::vi2d(0, 0) * vBlockSize, vBlockSize);
					break;
				case 1: // Draw Red Block
					DrawPartialSprite(olc::vi2d(x, y) * vBlockSize, sprTile.get(), olc::vi2d(1, 0) * vBlockSize, vBlockSize);
					break;
				case 2: // Draw Green Block
					DrawPartialSprite(olc::vi2d(x, y) * vBlockSize, sprTile.get(), olc::vi2d(2, 0) * vBlockSize, vBlockSize);
					break;
				case 3: // Draw Yellow Block
					DrawPartialSprite(olc::vi2d(x, y) * vBlockSize, sprTile.get(), olc::vi2d(3, 0) * vBlockSize, vBlockSize);
					break;
				}
			}
		}
		SetPixelMode(olc::Pixel::NORMAL); // Draw all pixels

		// Draw Ball
		FillCircle(vBallPos * vBlockSize, int(fBallRadius), olc::CYAN);

		// Draw Fragments
		for (auto& f : listFragments)
			DrawRotatedDecal(f.pos * vBlockSize, decFragment.get(), f.fAngle, { 4, 4 }, { 1, 1 }, f.colour);


		return true;
	}
};

int main()
{
	BreakOut demo;
	if (demo.Construct(512, 480, 1, 1))
		demo.Start();
	return 0;
}