Thank you. I'll try my best to explain it.
The easy but very laggy way to do pixel manipulation in Godot is usually by iterating through every pixel in two nested for loops and then reading or setting its new color value with get_pixel() or set_pixel(). These built-in functions are extremely slow because they run on a single CPU thread, processing each pixel sequentially.
This consumes a lot of CPU cycles and takes a significant amount of time.
In my game, which runs at a resolution of just 480×270, that's already 129600 iterations per frame. This is a heavy load and takes too long to compute. For example, here's a laggy check to see if a pixel has been completely burnt by fire. Calling this in _process() takes a lot of compute.
func check_for_burnt_out_pixels(current_image: Image, last_frame_image: Image, burnt_mask_image: Image) -> Image:
for y: int in range(current_image.get_height()):
for x: int in range(current_image.get_width()):
var last_pixel: Color = last_frame_image.get_pixel(x, y)
var curr_pixel: Color = current_image.get_pixel(x, y)
var already_emitted: bool = burnt_mask_image.get_pixel(x, y).r > 0.1
var just_burnt_out: bool = (last_pixel.g > 0.0 and curr_pixel.g < 1.0 and curr_pixel.g > 0.998047 and curr_pixel.a > 0.0)
if just_burnt_out and not already_emitted:
burnt_mask_image.set_pixel(x, y, Color(1.0, 0.0, 0.0))
return burnt_mask_image
And these kinds of checks also need to be done to determine if a pixel is wet, burning, repairable, or producing smoke. Running this at 60 FPS on a CPU with current hardware is practically impossible. Using threads makes it a bit better but still not ideal.
The alternative is to leverage the power of the GPU through shaders.
The GPU can run thousands of threads in parallel, each handling a single pixel (fragment). In a shader (like a fragment shader), each pixel is processed independently. This means that if you’re rendering a 480×270 texture, the GPU can compute 129,600 pixels with ease in a fraction of the time a CPU would.
In Godot, these are called canvas shaders, which you apply to materials via ShaderMaterial. Godot uses a shading language based on GLSL. shaders are not written in GDScript.
I accidentally left my debug tools in. You can set something on fire and then press Q to see the debug.
I do almost all heavy pixel manipulation in shaders, leaving only a minimal amount in GDScript when there’s no alternative. Shaders are essentially a one-way street: you can feed them variables at runtime, but you can’t read their internal variables back. Only the computed results can be written back into textures. This adds complexity and a few headaches, but the performance gain is worth the trade-off.
I have shaders that handle the calculations for fire, water, fuel, repair, avoidance areas, and smoke, along with their rule-based interactions and propagation. These are written into and fed by several texture masks representing fire, fuel, and water as RGBA values.
For example, burnable pixels must still have some green channel value to be considered ignitable. The green value gradually decreases to 0 as the fire consumes the pixel, and repairing restores the green channel back to 1. Water uses the blue channel, which slowly decreases when it touches fire (eventually evaporating). Fire uses the red channel.
In the end, I get a texture where each RGBA channel shows how much fire, fuel, and water each pixel holds. I then pass this into another shader that combines the final RGBA values with the pixel art, applying visual rules depending on how much fire, fuel, or water a pixel has. Pixels with low green and red values appear darker; those with both green and red show varying fire intensities; and pixels with blue appear wet.
With this method, I achieve around 120 FPS on desktop, and even in browsers performance stays acceptable at around 30–50 FPS.
There’s more to it, but this is already a super long explanation. Hope this helps!