Drawing 1000s of moving pixels on Godot
Coder Spirit

Drawing 1000s of moving pixels on Godot

Yesterday I released a new version of FakeLife (v0.0.2) that includes some performance improvements I want to talk about.

Introduction

The 1st feature I implemented for FakeLife was having 16k floating mineral grains (represented by a single pixel) following Brownian motion. Each one of them was managed by a Sprite node loaded with a 1x1px PNG texture, and it was FAST (~235fps for 16k objects on a 1024x1024px canvas).

Sadly, shortly after I introduced algae that needed to detect collisions against mineral grains, and this detail made it quite difficult to maintain the previous frame rate. I had to use a RigidBody2D node, and nest the Sprite inside it, together with another CollisionShape2D node.

Suddenly the frame rate had dropped to ~29fps šŸŒ.

A failed attempt: Drawing primitives

It is known that having too many nodes in our scene can make Godot struggle because of the added overhead. Thatā€™s why some people propose bypassing them and programming directly against Godotā€™s servers. I didnā€™t try that yet, because Iā€™m still learning Godot and I prefer to first master its high level concepts.

What I did try (given that I couldnā€™t get rid of the RigidBody2D because I needed collisions) is to replace the PNG Sprite by relying on drawing primitives, specifically draw_primitive, that allows us to draw single pixels.

The drawing primitives must be placed inside the special CanvasItemā€™s function _draw (which our objects inherit from), it looks like this:

func _draw() -> void:
  # It's trivial to set its coordinates to (0,0), because Godot takes care of
  # later applying a transformation/translation matrix.
  draw_primitive(
    PoolVector2Array([Vector2(0.0, 0.0)]), # position
    PoolColorArray([Color.darkslategray]), # color
    PoolVector2Array([Vector2(0.0, 0.0)])  # UVs (not needed in this context)
  )

Of course we can create constants for these parameters, so we donā€™t have to construct the objects every time, but this detail is not what should worry us right now.

It turns out this solution is terrible, itā€™s even slower than loading PNG textures! In my case, the frame rate dropped to 12fps.

Why is that? Well, it turns out that Sprite takes advantage of calls batching, while draw_primitive doesnā€™t (at least for single pixels).

Second attempt: Rectangles

If draw_primitive doesnā€™t take advantage of batching, do we have any other ā€œlow levelā€ function that does? Yes, draw_rect:

# We cache these 2 objects, as we'll reuse them a lot
const rect = Rect2(0.0, 0.0, 1.0, 1.0)
const color = Color(0.25, 0.25, 0.25)

func _draw() -> void:
	draw_rect(rect, color) # Takes advantage of batching :D

The performance gain is noticeable, but humble. We go from ~29fps to ~32fps, thatā€™s a 10% improvement. Not bad.

To recap: draw_rect faster than nested Sprite faster than draw_primitive.

Other considerations

As explained in my previous article, I avoided using the Godotā€™s physics engine because of its performance impact.

There are some cards I still have in my sleeve in order to further improve the simulationā€™s frame rate:

  • Using Godotā€™s servers, instead of having an individual node per particle.
  • Implementing some scripts in C# instead of using GDScript. I donā€™t have much faith in this approach, but it could be interesting to try, even if itā€™s just for the learning experience.
  • Going even further, Rust bindings perhaps?

Bevy Engine is an option Iā€™m also considering, but I prefer to work first with a less performant engine to be sure that Iā€™m not doing anything terribly wrong from a computational complexity perspective.

End

Thatā€™s it for today! In next articles Iā€™ll probably write about some other problems Iā€™ve had to face while developing FakeLife, such as designing genes that allow for the creation of multicellular organisms as an adaptive response.

See you soon!