Code a Lava Lamp With the Godot Game Engine

by blakeearth in Craft > Digital Graphics

1408 Views, 3 Favorites, 0 Comments

Code a Lava Lamp With the Godot Game Engine

finished.gif
teaser.gif
lava_lamp_base.png

This Instructable comes with free access to a corresponding video class that releases March 31. Sign up to get access in your email inbox when it drops. Get the class

In my first Instructable, which I created about 5 years ago, I taught you how to create a physical lava lamp using a wine bottle. In this project, we'll create an entirely digital lava lamp. The goal here is play and experimentation: the lava lamp is extremely customizable (think: base shape, speed, colors, amount of goo, glow).

This Instructable also serves as an introduction to 2D fragment shaders via a lava lamp project, often used in games and game development to achieve neat visual effects that run rapidly on the GPU via parallel processing. Being able to write shaders is a valuable skill that can add a lot of flair to your game projects!

Or if you just want to make a neat, super-customizable lava lamp pal, this is great for that too! As a bonus, I'll show you how you can keep your lava lamp running in the corner of your screen for 24/7 mesmerizing effects.

Supplies

You'll need a computer with Windows, Linux or macOS that can run the visual Godot Game Engine.

Some basic understanding of trigonomentry and code will be helpful but is not required--I'll explain every step individually.

You'll need the engine itself. Download Godot for your operating system and architecture here. Download the version of the engine that matches your operating system. If you download from the website, you get a self-contained executable that runs directly (not an installer.) You can also get the engine on Steam. If you do, it will come pre-loaded with lots of templates and starter projects to explore.

Lastly, you'll need a lava lamp base. You're also welcome to use mine, attached above. What's important about this image is that the area you want your lava lamp globe to be needs to be a solid color.

Observe the Lava Lamp

ezgif-4-2041aaaee011.gif

If you have a physical lava lamp, I encourage you to turn it on and spend a while observing it. There are a few characteristics I'll explicitly teach you how to emulate, but I encourage you to add more and experiment with it! Otherwise (for now), check out some footage.

Here are a few things to note:

  • The blobs often merge when they collide to form a single blob.
  • When alone, the blobs become more spherical.
  • The blobs tend to move quickly from the top to the bottom or vice versa, slow down when they reach their destination, then eventually switch sides quickly again.
  • The color of the blobs tends to be brighter at the bottom and darker at the top, since the light source is at the bottom and the liquid and other blobs can block the light.
  • The alcohol/water solution, if colorful, seems darker toward the edges of the lamp.

We're going to specifically target these features of lava lamps. What others have you noticed?

  • Example: sometimes, the blobs "overlap" or collide with each other. Our lava lamp will be flat. How can you achieve this?

Set Up Your Godot Project

create-project.PNG

Run the engine if you haven't done so already.

If you downloaded the Godot Game Engine from the website, once you've extracted the .zip file and tried to run Godot for the first time, you might run into a security warning from your operating system. On Windows, you can reassure Windows Defender by pressing "More info" and then "Run anyway". Godot is open-source software, so you can verify for yourself that the code is not harmful to your computer. Check their GitHub repository if you have concerns.

Press the "New Project" button to create a new Godot project. You'll then get this popup that wants a little more information from you, like a name, where to store the project on your computer, and which renderer to use.

First, I'll press "Browse" and navigate to my Desktop, since I want to put my new project on my Desktop. I'll press "Select Current Folder".

Now, Godot thinks I want my Desktop itself to be the project folder, but I actually want to put the project folder on my Desktop, so I'll name my project "Lava Lamp" and press "Create Folder" to create the new folder on my Desktop. Now, you can see I have a new folder called "Lava Lamp" on my Desktop.

Since I don't need this application to run in website environments, I'm going to choose OpenGL ES 3.0 as the renderer.

Then, I'll press "Create Project" and the engine will launch.

Create a Sprite and a CanvasItem Material and Shader

sprite-look.PNG
new-shader.png
white-shader.PNG

In Godot, nodes represent small building blocks of games. There are nodes for creating images, characters, UI elements, animations, and more. Since we're focused on writing a shader for this tutorial, we don't need many nodes, but we do need one node that we can apply our shader to. This node should also draw our lava lamp base.

We can write 2D shaders via materials for any node that inherits from CanvasItem. To show you which nodes these are, I'm going to press "Other Node". You can see that this opened a window including an index of all nodes you can have in Godot. We're looking for nodes that inherit from CanvasItem so we can apply shaders to them, and we also want that node to display our lava lamp base. So I'll choose the Sprite node, which we can find under CanvasItem, Node2D, Sprite. I'll press "Create" to add this node to the current scene.

Now, you can see I have the Sprite over here on the left as the root node of my current scene. On the right, we have Godot's inspector, which describes various properties of the Sprite. I'll drag icon.png from the folder in the bottom left to the "Texture" position for now. You can see that some of these other properties are inherited from Node2D and from CanvasItem. Underneath CanvasItem, we have a Material dropdown and a Material property. Choose the dropdown arrow next to the Material property and press "New ShaderMaterial", then next to "Shader" do the same and pick "New Shader".

You'll see a coding window pop up at the bottom here. It's important to note that what we're writing here is NOT GDScript (which you might have used in a previous tutorial from me) but Godot's unique shader language similar to GLSL.

Let's start by defining a fragment shader that simply sets the COLOR at every point covered by the Sprite to white. First, we need to define the shader type: Since this is a 2D shader for a CanvasItem, we'll write

shader_type canvas_item;

Next, we'll implement the fragment function. This function we'll write is going to be called by engine at each individual point our shader runs on. We write void because this function does not return anything back to the function that called it, we use parenthesis to delineate function parameters (there are none here), and we use curly braces to mark the region of code that this function will run.

shader_type canvas_item;

void fragment() {

}

Lastly, we'll set the COLOR at this individual fragment (remember, the fragment function runs at each point on the screen covered by the sprite) to white. We represent "white" using a vec4, which in this case just means an ordered collection of numbers. Each element of the vec4 represents a component of the final color: first red, then green, then blue, then alpha (like opacity). For solid white, we want all four of these elements to be 1.0 (the maximum for each), likevec4(1.0, 1.0, 1.0, 1.0);. We can use also use shorthand for this and construct the vec4 like below:

shader_type canvas_item;

void fragment() {
	COLOR = vec4(1.0);
}

Now, you should see a white square where the Godot icon used to be in the editor!

Create the First Blobs (Metaball-like)

first-blobs.PNG

Here's our approach for creating the basic, black-and-white blobs:

  1. Set the color to black.
  2. Create a list of vec3s containing 3 numbers: x and y positions on the Sprite representing the center of each blob, and the "strength" that that blob has to influence nearby points (we can think of this as roughly analogous to the blob's size).
  3. Create a number (call it "influence") (starting at 0) that will represent how much this point is influenced by the blobs.
  4. For each point processed by the fragment function, we'll calculate its distance from each of the blob positions.
  5. Based on how close it is to each blob position, we'll add a multiple of that blob position's "strength" to "influence". (This multiple should be larger when the distance is smaller!)
  6. If "influence" is above a certain threshold, say, 1.0, set the color to white.

Let's start with two blobs:

// this is a 2D CanvasItem shader
shader_type canvas_item;

// 
void fragment() {
	// color each fragment the Sprite covers black
	COLOR = vec4(0.0, 0.0, 0.0, 1.0);
	// declare the blob_centers as
	// a changing array of vec3s
	// with length 2 (2 elements)
	vec3 blob_centers[2];
	// define the blob centers
	// we start counting at 0
	blob_centers[0] = vec3(0.5, 0.7, 0.5);
	blob_centers[1] = vec3(0.3, 0.6, 0.3);
	// start counting influence at 0
	float influence = 0.0;
	// for each of the blobs, we add some influence based on how close
	// this point is to each blob
	for (int i = 0; i < blob_centers.length(); i++) {
		// TODO: explain each of these variables
		float distance_to_blob_center = distance(blob_centers[i].xy / TEXTURE_PIXEL_SIZE, UV / TEXTURE_PIXEL_SIZE);
		influence += blob_centers[i].z * (1.0 / distance_to_blob_center);
	}
	// if influence is larger than a certain threshold, set the color at this
	// point to white
	if (influence > 0.1) {
		COLOR = vec4(1.0);
	}
	
}

Animate the Blobs

white-blobs.gif

First, let's add a few more blobs to animate. Since we add to influence for each blob we add, you might need to increase the threshold.

// this is a 2D CanvasItem shader
shader_type canvas_item;

const float threshold = 0.2;

void fragment() {
	// color each fragment the Sprite covers black
	COLOR = vec4(0.0, 0.0, 0.0, 1.0);
	// declare the blob_centers as
	// a changing array of vec3s
	// with length 2 (2 elements)
	vec3 blob_centers[6];
	// define the blob centers
	// we start counting at 0
	blob_centers[0] = vec3(0.5, 0.7, 0.5);
	blob_centers[1] = vec3(0.3, 0.6, 0.3);
	blob_centers[2] = vec3(0.2, 0.3, 0.5);
	blob_centers[3] = vec3(0.1, 0.8, 0.3);
	blob_centers[4] = vec3(0.5, 0.4, 0.5);
	blob_centers[5] = vec3(0.2, 0.5, 0.3);
	// start counting influence at 0
	float influence = 0.0;
	// for each of the blobs, we add some influence based on how close
	// this point is to each blob
	for (int i = 0; i < blob_centers.length(); i++) {
		// TODO: explain each of these variables
		float distance_to_blob_center = distance(blob_centers[i].xy / TEXTURE_PIXEL_SIZE, UV / TEXTURE_PIXEL_SIZE);
		influence += blob_centers[i].z * (1.0 / distance_to_blob_center);
	}
	// if influence is larger than a certain threshold, set the color at this
	// point to white
	if (influence > threshold) {
		COLOR = vec4(1.0);
	}
	
}

Next, we'll define a new function that we'll use to move the blobs up and down based on time. We'll use sin^2(x), where x is TIME (in seconds) since the shader started, for a nice oscillation effect. This gives us the effect we were looking for: the blobs travel more quickly in the middle and slow down toward the top and bottom of the lamp. Note that it's important to define the new function above the fragment function so the fragment function is able to use it.

float oscillate(float x, float offset, float speed) {
	return pow(sin(speed * x + offset), 2);
}

Replace the y component of each of the blob center vec3s with some version of our new oscillate function. You should now see the white blobs slowly glide back-and-forth, up and down the Sprite.

	blob_centers[0] = vec3(0.5, oscillate(TIME, 0.2, 0.2), 0.5);
	blob_centers[1] = vec3(0.4, oscillate(TIME, 0.5, 0.1), 0.3);
	blob_centers[2] = vec3(0.6, oscillate(TIME, 0.3, 0.3), 0.5);
	blob_centers[3] = vec3(0.4, oscillate(TIME, 0.1, 0.1), 0.3);
	blob_centers[4] = vec3(0.5, oscillate(TIME, 0.4, 0.1), 0.5);
	blob_centers[5] = vec3(0.6, oscillate(TIME, 0.3, 0.2), 0.3);

Add Some Color (Gradients and Glow)

blobs.gif

In this step, we'll add some gradients to the color of our blobs, and we'll change the background color from black to a color more fitting for lava lamp liquid, again in gradient form. I'll copy the lava lamp colors from the one I observed at the beginning of the Instructable, but again, I really encourage you to play with the colors and try a bunch of combinations!

Here's the general approach for how this is going to work:

  1. At each fragment, if this is not a fragment on a blob (i.e.) we've assigned its color black, instead assign its color to a linear interpolation between two colors, or a mix between two colors, using this fragment's x-distance from the center of the lamp to determine which of each color to use.
  2. Similarly, if this is a blob fragment, instead assign its color to a linear interpolation between two colors, or a mix between two colors, using this fragment's y-value relative to the whole Sprite.
  3. Add a second, smaller threshold to measure where the glow should be (around each blob), and mix colors between this blob's y-color at this point and the background (non-blob) color at this point using the threshold to create the ratio.

This is also a stage where you have a lot of freedom to tweak the various inputs to dramatically change the outcomes.

First, let's define a few uniforms. Uniforms are outside input from the engine, and adding them makes it possible to change the values (like colors, blob size, speed, etc.) from the editor or from code. Since we can use the editor's color picker, they also make customization easier! Adding "hint_color", for example, tells the editor this input is a color and allows us to use its built-in color picker, instead of having to rely on our knowledge of RGBA.

uniform float blob_threshold = 1.0;

uniform vec4 background_edge: hint_color = vec4(1.0, 0, 0.6, 1.0);
uniform vec4 background_center: hint_color = vec4(0.4, 0, 1.0, 0.4);

uniform vec4 blob_top: hint_color = vec4(1.0, 0.4, 0.4, 1.0);
uniform vec4 blob_bottom: hint_color = vec4(1.0, 1.0, 0.3, 1.0);

Here's the finished code for this step (note that I changed the strength of each blob and tweaked influence as well!)

// this is a 2D CanvasItem shader
shader_type canvas_item;

uniform float blob_threshold = 1.0;

uniform vec4 background_edge: hint_color = vec4(1.0, 0, 0.6, 1.0);<br>uniform vec4 background_center: hint_color = vec4(0.4, 0, 1.0, 0.4);<br><br>uniform vec4 blob_top: hint_color = vec4(1.0, 0.4, 0.4, 1.0);<br>uniform vec4 blob_bottom: hint_color = vec4(1.0, 1.0, 0.3, 1.0);

float oscillate(float x, float offset, float speed) {
	return pow(sin(speed * x + offset), 2);
}

void fragment() {
	// color each fragment the Sprite covers black
	vec4 background_color_here = mix(background_edge, background_center, abs(0.5 - UV.x));
	COLOR = background_color_here;
	// declare the blob_centers as
	// a changing array of vec3s
	// with length 2 (2 elements)
	vec3 blob_centers[6];
	// define the blob centers
	// we start counting at 0
	blob_centers[0] = vec3(0.5, oscillate(TIME, 0.2, 0.2), 2.0);
	blob_centers[1] = vec3(0.4, oscillate(TIME, 0.5, 0.1), 1.0);
	blob_centers[2] = vec3(0.6, oscillate(TIME, 0.3, 0.3), 3.0);
	blob_centers[3] = vec3(0.4, oscillate(TIME, 0.1, 0.1), 2.0);
	blob_centers[4] = vec3(0.5, oscillate(TIME, 0.4, 0.1), 5.0);
	blob_centers[5] = vec3(0.6, oscillate(TIME, 0.3, 0.2), 3.0);
	// start counting influence at 0
	float influence = 0.0;
	// for each of the blobs, we add some influence based on how close
	// this point is to each blob
	for (int i = 0; i < blob_centers.length(); i++) {
		// TODO: explain each of these variables
		float distance_to_blob_center = distance(blob_centers[i].xy / TEXTURE_PIXEL_SIZE, UV / TEXTURE_PIXEL_SIZE);
		influence += blob_centers[i].z * (1.0 / distance_to_blob_center);
	}
	// if influence is larger than a certain threshold, set the color at this
	// point to the gradient
	COLOR = mix(background_color_here, mix(blob_top, blob_bottom, UV.y), pow(influence, 6));
	if (influence > blob_threshold) {
		COLOR = mix(blob_top, blob_bottom, UV.y);
	}
	
}

Apply a Base

finished.gif

In this step, we'll switch from overwriting the basic Godot icon to using a PNG of a lava lamp base. You're welcome to use my lava lamp base image or create your own; what's important about the image is that you know the exact color of the region to replace with the shader we've created so that we can replace that color with our shader. I created mine using Blender.

Here's our general approach:

  1. Replace the Godot icon with the lava lamp base, keeping track of the color to replace with the globe shader.
    1. When you import the base image, it's very important that you disable filtering from the "Import" tab.
  2. In our shader, if the COLOR is exactly equal to the color we want to replace, continue with our existing code. Otherwise, use the color defined by the texture at this point.
  3. Adjust our functions and gradients to account for the fact that 1.0 no longer represents the top of the globe, and 0.0 no longer represents the bottom of it.

Here's our finished shader code!

// this is a 2D CanvasItem shader
shader_type canvas_item;

uniform float top;
uniform float bottom = 1.0;

uniform float blob_threshold = 1.0;

uniform vec4 background_edge: hint_color = vec4(1.0, 0, 0.6, 1.0);
uniform vec4 background_center: hint_color = vec4(0.4, 0, 1.0, 0.4);

uniform vec4 blob_top: hint_color = vec4(1.0, 0.4, 0.4, 1.0);
uniform vec4 blob_bottom: hint_color = vec4(1.0, 1.0, 0.3, 1.0);

float oscillate(float x, float offset, float speed) {
	return pow(sin(speed * x + offset), 2) * (top - bottom) + bottom;
}

void fragment() {
	// color each fragment the Sprite covers according to the texture
	COLOR = texture(TEXTURE, UV);
	if (COLOR == vec4(1.0, 0.0, 1.0, 1.0)) {
		vec4 background_color_here = mix(background_center, background_edge, abs(0.5 - UV.x));
		COLOR = background_color_here;
		// declare the blob_centers as
		// a changing array of vec3s
		// with length 2 (2 elements)
		vec3 blob_centers[6];
		// define the blob centers
		// we start counting at 0
		blob_centers[0] = vec3(0.5, oscillate(TIME, 0.2, 0.2), 80.0);
		blob_centers[1] = vec3(0.4, oscillate(TIME, 0.5, 0.1), 60.0);
		blob_centers[2] = vec3(0.6, oscillate(TIME, 0.3, 0.3), 50.0);
		blob_centers[3] = vec3(0.4, oscillate(TIME, 0.1, 0.1), 30.0);
		blob_centers[4] = vec3(0.5, oscillate(TIME, 0.4, 0.1), 50.0);
		blob_centers[5] = vec3(0.6, oscillate(TIME, 0.3, 0.2), 30.0);
		// start counting influence at 0
		float influence = 0.0;
		// for each of the blobs, we add some influence based on how close
		// this point is to each blob
		for (int i = 0; i < blob_centers.length(); i++) {
			// TODO: explain each of these variables
			float distance_to_blob_center = distance(blob_centers[i].xy / TEXTURE_PIXEL_SIZE, UV / TEXTURE_PIXEL_SIZE);
			influence += blob_centers[i].z * (1.0 / distance_to_blob_center);
		}
		// if influence is larger than a certain threshold, set the color at this
		// point to the gradient
		COLOR = mix(background_color_here, mix(blob_top, blob_bottom, (UV.y - top) / (bottom - top)), pow(influence, 6));
		if (influence > blob_threshold) {
			COLOR = mix(blob_top, blob_bottom, (UV.y - top) / (bottom - top));
		}
	}
}

Bonus! Keep Your Lava Lamp on Your Desktop or Screen

on-top.gif

I like to keep my lava lamp in the bottom right corner of my screen. It's a nice little widget for mild entertainment while I'm working. The Godot Game Engine provides everything we need to achieve this!

In the top left corner of the engine, pick "Project" > "Project Settings" then scroll down to "Display" > "Window". Adjust the size of the window to the size (in pixels) you want your lava lamp to be.

While in "Project Settings" > "Window", tick "Always on Top", and allow and enable per pixel transparency. Then, add a script to your Game scene with the following code:

func _ready():
	get_tree().get_root().set_transparent_background(true)

Close out of the project settings. You'll notice the blue box representing the size of the game window has updated. Using the lower right-hand corner and shift, scale your Sprite to match the box.

To change where the window appears on your screen, navigate to "Editor" > "Editor Settings", then scroll to "Run" > "Window Placement", set "Rect" to "Custom Position" and set "Rect Custom Position" to your screen size minus your lava lamp size.

Then press the Play icon to bring up your lava lamp! You can keep it on top of your work and drag it around if i gets in the way.