Yesterday I released a new version of FakeLife (v0.0.2) that includes some performance improvements I want to talk about.
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
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
node, and nest the
Sprite inside it, together with another
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
draw_primitive, that allows us to draw single pixels.
The drawing primitives must be placed inside the special
_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.
Second attempt: Rectangles
draw_primitive doesn’t take advantage of batching, do we have any other
“low level” function that does? Yes,
# 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.
draw_rect faster than
nested Sprite faster than
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.
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!