Site icon davidepesce.com

Godot Tutorial – Procedural generation of fire starting from any shape

Fire is a widespread element in video games (think of explosions, campfires, spells, etc.), so every game developer will have to deal with it sooner or later.

There are two possible approaches to create fire:

In this tutorial, we will follow the second approach – we will generate it procedurally. Since my interest is in pixel art, we will create a fire suitable for games that use this art style.

To generate fire, we will start from an image that will define its starting color and shape. The generation algorithm will take this image as an input and create flames with that shape.

Open your drawing software, draw a shape, and set it on fire! It’s as simple as that.

Project files

I prepared a project containing the assets we will use during the tutorial to get started quickly. To download it, click the button below:

Download “FireSprite Tutorial”

FireSprite-Tutorial.zip – Downloaded 560 times – 32.40 KB

The zip file also contains the complete final result.

The algorithm

Before starting to program, let’s analyze the problem we want to solve, investigating the fire properties to find an algorithm to animate it.

What is fire? Fire is a combustion process that generates heat, light, and reaction products (smoke). The visible part of the fire, the flame, is made up of hot gases, and it is the part we are interested in reproducing here.

Let’s start by identifying the colors present in a flame. Since we want to achieve a pixel art effect, we will build a limited color palette. If we look at the image above, we can identify some areas:

So, we can build a palette that makes use of the colors seen above:

Now that we have chosen the colors to draw the flame and the smoke, let’s analyze its movement.

As we said earlier, the flame is hot gas. Without going into the details of gas physics, we know that hot gases tend to rise. Consequently, we will need to animate our flame’s pixels so that they appear to have an upward movement. To this vertical movement, we must add a random movement (Brownian motion) due to the gas’s thermal energy.

To summarize, our flame will consist of pixels that:

We will implement the fire algorithm by dynamically creating a texture, which we will modify by reading and writing its pixels directly.

At each frame of the animation, we must:

A) copy the initial image over current fire’s image (it represents the source of the flame and must be restored at each frame). This image must be drawn with the palette’s first color (in our case, red).

B) randomly choose a certain number of pixels of the texture. A selected pixel may or may not be part of the flame (depending on whether it has a color or is entirely transparent).

For each pixel that is part of the flame:

C) copy it to a random pixel among the three above it. We can adjust the flame’s height and intensity by performing this operation only on some of the pixels (randomly selected).

D) change the color of the current pixel with the next one in the palette, or make it completely transparent if we are at the last color.

By repeating these steps periodically, we will get a somewhat realistic animation of a flame, in pixel art style.

Creating the FireSprite scene

Open the FireSprite-Starting-Point project. Once open, click on Scene -> New Scene to create a separate scene for our flaming sprite. Click on 2D Scene to create a root node of type Node2D and rename it FireSprite.

We’ll need a Sprite node to show the dynamically generated fire texture, so add it to FireSprite. Also, we’ll need to add a Timer to set the texture update frequency. I have set the Wait Time property of my timer to 0.1 seconds to animate the fire at 10 FPS, but you can set it to whatever value suits your other game animations more. Finally, enable the Autostart property.

Your final scene node tree will be this:

Save the scene as FireSprite.tscn. Now, attach a new script to FireSprite, and save it as FireSprite.gd.

Add the tool keyword to the beginning of the script, before the extends Node2D statement. In this way, the script will also run inside the editor, allowing us to see the effects of what we are doing in real-time.

Setting the starting texture

Before writing the script, it is good to know the types of nodes/objects that we will use to handle images in this project:

To animate the fire, we will edit the data inside an Image variable, and for each frame, we will create an ImageTexture object to be assigned to the Sprite node.

To begin, add two variables: one will contain our starting texture (the one that will determine the shape of the flame), and the other will contain the generated fire image:

export var texture: Texture = null setget set_texture, get_texture
var image: Image

func set_texture(value):
	texture = value
	if value != null:
		pass #TODO: generate image and set Sprite's texture
	else:
		$Sprite.texture = null

func get_texture():
	return texture

We need to use getters and setters for the texture variable because we have to perform some operations every time it is changed:

  1. generate a new image of the right size;
  2. create the texture from the image and assign it to the Sprite node.

Since we will perform these operations in multiple points in the script, we will create two functions.

For step 1, add the reset_image() function:

func reset_image():
	image = Image.new()
	var width = texture.get_width() + 20
	var height = texture.get_height() + 48
	image.create(width, height, false, Image.FORMAT_RGBA8)
	image.lock()
	image.blit_rect(texture.get_data(), Rect2(Vector2.ZERO, texture.get_size()), Vector2(10, height - texture.get_height()))
	image.unlock()
	$Sprite.position.y = -(image.get_height() - texture.get_height()) / 2

How does it work?

The first line creates a new empty Image object. To create the actual image, the create() method is called, to which we must pass as arguments: the image size, if we want to use mipmaps, and the image format. The image we create will be 20 pixels horizontally and 48 vertically larger than the starting image’s size because we need additional space to allow the flames to grow. As for the format, we will use RGBA8 (4x 8-bits channels).

The next line locks the image data for reading and writing access. With the blit_rect() function, we copy the starting image (which we get from the texture variable using the get_data() function) inside our fire image. We copy it in the lower part because we need the extra vertical space we added above the starting image. With the unlock() function, we prevent other changes to the image data.

The last line changes the position of the Sprite node to center the starting image with the origin of the FireSprite node.

For step 2, add a second function, update_texture():

func update_texture():
	var imageTexture = ImageTexture.new()
	imageTexture.create_from_image(image, 0)
	$Sprite.texture = imageTexture

This function creates a new ImageTexture object and assigns the fire image to it using the create_from_image() function. The second parameter of the function (used to set texture’s flags) is 0 to disable the FILTER flag so that the image is drawn pixel perfect.

Finally, the function assigns the texture to the Sprite node.

Now we can go back to the set_texture() function, and update it to use these two functions:

export var texture: Texture = null setget set_texture, get_texture
var image: Image

func set_texture(value):
	texture = value
	if value != null:
		reset_image()
		update_texture()
	else:
		$Sprite.texture = null

func get_texture():
	return texture

Go to the main scene, and add the FireSprite.tscn scene to it. By selecting the FireSprite node, in the Inspector, you will see the Texture property corresponding to the script’s texture variable. Drag the pentacle.png file onto it: the image of a red pentacle will appear on the screen.

The time has finally come to set the image on fire!

Fire animation

Return to the FireSprite.tscn scene, select the Timer, and connect the timeout() signal to the FireSprite node. The _on_Timer_timeout() function will be automatically added to the FireSprite.gd script.

Before writing this function, let’s add two new variables:

export(float, 0.1, 1.0, 0.01) var fire_intensity : float = 0.5
export var palette = [ Color.red, Color8(152,34,7), Color8(250,240,21), Color8(249,187,11), Color8(248,137,9), Color8(0,0,0,140), Color8(0,0,0,48) ]

Now we can implement the algorithm that we saw earlier inside the _on_Timer_timeout() function. The letters in the comments correspond to the steps of the algorithm:

func _on_Timer_timeout():
	if texture == null:
		return
	
	image.lock()
	
	# A - Copy original texture over the fire image
	image.blit_rect_mask(texture.get_data(), texture.get_data(), Rect2(Vector2.ZERO, texture.get_size()), Vector2(10, image.get_height() - texture.get_height()))
	
	# B - Random selection of pixels
	var test_count = image.get_width() * image.get_height()
	for i in range(test_count):
		var x = randi() % image.get_width()
		var y = randi() % image.get_height()
		var color = image.get_pixel(x, y)
		if color.a > 0:
			# Get the palette index of the current pixel
			var color_index = -1
			for j in range(palette.size()):
				if abs(color.r8 - palette[j].r8) <= 1 and abs(color.g8 - palette[j].g8) <= 1 and abs(color.b8 - palette[j].b8) <= 1 and abs(color.a8 - palette[j].a8) <= 1:
					color_index = j
					break
			
			# C - Copy color in one of the pixel above the current one
			if y > 0 and randf() <= fire_intensity:
				var new_x = x + randi() % 3 - 1
				if new_x >= 0 and new_x < image.get_width():
					image.set_pixel(new_x, y - 1, color)
			
			# D - Update color of the current pixel
			if color_index >= 0 and color_index < (palette.size() - 1):
				image.set_pixel(x, y, Color(palette[color_index + 1]))
			else:
				image.set_pixel(x, y, Color.transparent)
					
	image.unlock()
	update_texture()

Let’s analyze this function piece by piece.

if texture == null:
	return
	
image.lock()

Here we simply check that the texture is set. If it isn’t, we immediately exit from the function. Otherwise, we lock the fire image for editing.

# A - Copy original texture over the fire image
image.blit_rect_mask(texture.get_data(), texture.get_data(), Rect2(Vector2.ZERO, texture.get_size()), Vector2(10, image.get_height() - texture.get_height()))

This line of code restores the fire source, copying the original image via the blit_rect_mask() function. If we didn’t, the fire would go out in seconds (not good for a fire, but a nice effect that we can use for explosions, for example).

# B - Random selection of pixels
var test_count = image.get_width() * image.get_height()
for i in range(test_count):
	var x = randi() % image.get_width()
	var y = randi() % image.get_height()
	var color = image.get_pixel(x, y)
		if color.a > 0:

This section randomly chooses a number of pixels equal to the number of pixels that make up our image. We then read the color of these pixels and check if they are part of the flame (checking if the transparency value is greater than 0).

Some of you may be wondering: why not just loop all the pixels? For example like this:

for x in range(0, image.get_width()):
	for y in range(0, image.get_height():
		var color = image.get_pixel(x, y)
			if color.a > 0:

We don’t use such a loop because it always processes all pixels, and always does it in the same order. On the other hand, by randomly choosing coordinates, it’s not guaranteed that all pixels are processed, and some could be processed even more than once (and certainly not always in the same order). This increases the flame’s randomness and gives us a better final result, especially near the source (but don’t believe me, try it!).

# Get the palette index of the current pixel
var color_index = -1
for j in range(palette.size()):
	if abs(color.r8 - palette[j].r8) <= 1 and abs(color.g8 - palette[j].g8) <= 1 and abs(color.b8 - palette[j].b8) <= 1 and abs(color.a8 - palette[j].a8) <= 1:
		color_index = j
		break

Once we get the current pixel color, we need to find its index in the palette. Unfortunately, we cannot make a simple comparison due to rounding errors, but we have to check that the colors are “almost equal”. These rounding errors are due to the fact that color components are stored as floats in Color-type variables, while as 8-bit integers in the image.

# C - Copy color in one of the pixel above the current one
if y > 0 and randf() <= fire_intensity:
	var new_x = x + randi() % 3 - 1
	if new_x >= 0 and new_x < image.get_width():
		image.set_pixel(new_x, y - 1, color)

This code copies the current pixel into one of the three pixels above it, but only with a probability equal to the fire_intensity variable’s value.

# D - Update color of the current pixel
if color_index >= 0 and color_index < (palette.size() - 1):
	image.set_pixel(x, y, Color(palette[color_index + 1]))
else:
	image.set_pixel(x, y, Color.transparent)

The last step is to change the current pixel color by taking the next one in the palette (or making it transparent if we have reached the last color).

Now select FireSprite and choose a Fire Intensity (I set it to 0.3). If you run the project now, you will finally see the pentacle catch fire!

Custom palette

You are not limited to just the colors of a standard flame. In the Inspector, you can change the default palette or create your own from scratch. The only thing to keep in mind is that you must draw the starting image with the palette’s first color.

In the FireSprite-Complete project, you will find an example of a magical flame that changes from blue to green.

Live preview

The last feature I want to add is the ability to have a live preview of the fire in the editor to see the effects of parameter changes immediately.

Doing so is very simple: add the live_preview variable, which will correspond to a checkbox in the Inspector that will activate/deactivate the preview. In the setter function, the code will start or stop the timer, depending on the live_preview value.

export var live_preview : bool = false setget set_live_preview, get_live_preview

func set_live_preview(value):
	live_preview = value
	if live_preview:
		$Timer.start()
	else:
		$Timer.stop()
		reset_image()
		update_texture()

func get_live_preview():
	return live_preview

Conclusions

In this tutorial, we have seen how interesting graphic effects can be made simply by reading and writing the texture’s image data. This system can be used as an alternative to shaders when using the latter can be too complex.

But keep in mind that these operations, unlike shaders, run on the CPU. Therefore, it is best to use them for small images, or ones that don’t need to be updated too often. With FireSprite we are already at the limit, I advise you not to use too many of them at the same time to avoid frame rate drops.

I want to conclude by suggesting some possible improvements:

That’s all! If you use these techniques in your project let me know, I can’t wait to see what you can come up with!

Did you enjoy this article? Then consider buying me a coffee! Your support will help me cover site expenses and pay writers to create new blog content.

Exit mobile version