The LibGDX performance guide

Yair Morgenstern
9 min readFeb 13, 2022

I’m the developer of Unciv, an Android/Desktop LibGDX application. During the 5+ years I’ve been working on the app, I’ve learnt a lot of things that would have helped me earlier in my journey, and this is a summary of those points :)

I’ll start off with a bunch of pointers so you can skip right to the part that interests you:

  • Don’t try and guess your bottlenecks — use a performance profiler
  • For new projects, Kotlin saves headaches
  • Separate heavy actions to a different thread
  • Minimize texture swapping
  • Add helper functions in subclasses for performance, profiling and debugging
  • Memory Performance
  • General coding performance — do less, exit faster, and cache results

Rule 0 — Don’t try and guess your bottlenecks

Who says you even have a performance problem? Who says you’re optimizing the right thing? Without data, you won’t be able to tell if you’ve made things better or worse.
I personally use Android Studio’s Android performance profiler for both CPU and Memory. Whatever your profiler, I recommend using a flame graph to view the results — for me, the instant readability of the flame graph and being able to drilldown instantly while keeping perspective of the entire run made this an amazing tool!

You can mouseover every individual part, and the callstack below it provides the context for what’s happening

For new projects, Kotlin saves headaches

I started the project using Java, and after about a year of work migrated to Kotlin. The difference was astounding — about 20% less lines of code. So much fluff disappeared, functions were easy to pass around, sequences were simple, and the null typechecking saved me from more silly errors than I can count. Is this really a performance tip? Not as such, but it made such a difference I couldn’t not add it to the list.

Separate heavy actions to a different thread

What to do?

  • Don’t run heavy logic in draw()
  • Split heavy data gathering to another thread, then sync the UI in a Gdx.app.postRunnable{ }

Why?

When CPU profiling, you’ll see there’s a thread called GLThread. LibGDX uses GL 2.0/3.0 to render, and this is the thread that holds the GL context. That means that every change to the UI has to be done through this thread, otherwise bad things happen — textures don’t load, UI elements are changed concurrently from different places, it can be a mess.

The problem is that using this thread leaves the user stuck. Even if it’s just a “Loading…” screen, if the user clicks it and there’s no handling of the event within 500ms, he’ll get an “Application Not Responding” error on his Android.

Draw() is the function that your code should hopefully be running 60 times a second. Every frame you miss will be felt by the players as lag. If you have many things on-screen, even just the drawing and acting take up a considerable amount of time, there’s no need to add latency yourself :)

So if you have IO operations (reading a file, retrieving data from a URL), you can create a separate thread to gather the information and then handle them by ‘posting’ a function that the main thread will run when it has time:

thread {
val results = getResultsFromIO()
Gdx.app.postRunnable {
handleResults(results)
}
}

Remember that even if the thread fails, you’ll probably want the user to know, and that will require a postRunnable as well!

For advanced users, you can wrap both thread and postRunnable in other functions to catch errors —threads may fail silently, and it’s always better to know you have a problem!

Minimize texture swapping

What to do?

  • Pack your images with TexturePacker, with power-of-two, at 2048*2048px
  • If it doesn’t all fit in one texture, you can split images into folders/textures by what renders together
  • Check Texture rendering order for long draw() functions

Why?

The worst thing you can do is to read an image file for every single image you want to draw, so for 10 circles you’ll read the same file 10 times. Each time you read a file, that data will stay in your RAM and you’ll reach high memory consumption really fast.

The second worst thing you can do is to save the texture of each file once, even if we’re using it in many different places (i.e. Images), but not to pack images together.

Every frame, the SpriteBatch renders all the images on the screen. GL can ‘bind’ a texture to load it in memory, and then drawing it even a thousand times takes basically no time at all. The problem is that unloading the previous texture and loading the new one does take time — and a lot.

This has many implications:

A. Packing multiple images into a single texture eliminates the need for this texture swapping, therefore making frame drawing faster. That’s why the good folks at LibGDX created TexturePacker, which takes all your images and compiles them to a few larger files.

I recommend packing to 2048*2048 files — some GPU chipsets require power-of-two sizes, and many chipsets can’t handle 4096, so that seems to be the sweet spot for now.

B. If your images are too much to fit into a single 2048*2048 texture, you should split apart images that are rarely used, or are used on different screens. Packing them together won’t make a difference., and by splitting them into a different texture, you eliminate the texture swapping when rendering your main images.

C. Since text rendering also requires a texture, because your letter images come from somewhere, they’re also subject to the limitations of texture swapping.So if you’re rendering text, or have multiple textures to work with, you should order your renders to minimize swapping.

This is one reason why games are sometimes divided into ‘render layers’. Say you have say 16 squares, and need on each to render a background, item and text, each of which is located on a different texture. If you render tile-by-tile, then you’ll be swapping textures 3 times per tile. If instead you render all background, then all items, etc, you’ll only have 3 texture swaps total!

Add helper functions in subclasses for performance, profiling and debugging

What to do?

Add functions in Actor subclasses for UI functions (act, draw, hit) on classes which have performance issues

Why?

LibGDX UI is really good, and it gives you many options for each object — you can easily add actions, listeners (like onClick), and it’s very composable. But this very flexibility also leads to performance issues — if you have 10,000 items onscreen, it doesn’t know which of them requires what, so it tries doing everything on everything.

You can solve some of these issues by overloading the function yourself and telling it that it’s not relevant. So for example if you have a lot of Group objects, but you aren’t assigning any actions to them, and most of them aren’t clickable, you can create the following class to return an instant negative response when not relevant:

/** A lot of the render time was spent on snapshot arrays of the TileGroupMap's groups, in the act() function.
* This class is to avoid the overhead of useless act() calls. */
open class ActionlessGroup(val checkHit:Boolean=false):Group() {
override fun act(delta: Float) {}
override fun hit(x: Float, y: Float, touchable: Boolean): Actor? {
if (checkHit)
return super.hit(x, y, touchable)
return null
}
}

This is also helpful for the profiling itself. If you have a table in a group in a table in a group etc, your profiler will helpfully show you the callstack of where things are taking a long time — but you will have no idea what object that is!

To solve this, you can create a mini-class that will only help your profiling by adding a helper function, like so:

class UnitLayerGroup:Group(){
override fun draw(batch: Batch?, parentAlpha: Float) = super.draw(batch, parentAlpha)
}

Your profiler will now both segregate this class’s draw() functions from the rest, and tell you that it’s this class’s function.

Also, as a sidenote — Group()’s isTransform is set to true by default, and calculating the transformation also takes a non-negligible amount of time, so as a rule of thumb I set all Group isTransform to false unless otherwise necessary.

You’ll know you won when you start seeing glClear taking a long amount of time — that means you’ve reached the maximum amount of FPS and glClear is purposefully wasting time throttling the framerate :)

Memory Performance

What to do?

Try out memory performance at least once, for low-hanging fruit

Why?

Usually when talking about performance we mean ‘runtime’, but if you’re using LibGDX that probably means you’re aiming for multi-platform, and lots of people have low-end phones with limited memory, making this an important aspect as well.

If you’re using Android Studio, a relatively straightforward method of getting started is by running the Android profiler, clicking ‘memory’, and recording Java/Kotlin allocations. This can help you discover which functions are allocating a lot of memory, and this is a good indicator of where you can improve. Also, often memory allocation is correlated with runtime, so this helps runtime performance as well :)

If you’ve never done memory performance improvements, you can usually quite quickly find issues that decrease consumption considerably.

The drilldown graph helps you find functions that are taking an inordinate amount of memory

Another option is taking a memory dump at a specific time, so you can load that dump and analyze it at your leisure. This is helpful is finding memory leaks — lots of allocated memory that for some reason isn’t cleaned up by the Garbage Collector.

OpenGL Memory

You may see that the allocated memory, and the memory dumps, don’t contain the entirety of the memory used by your app. This is because OpenGL memory is not managed by the JVM GC, and thus if you’re creating your own textures, you will need to manually dispose of them or risk a hard-to-debug memory leak.

There’s a simple way of checking if your memory leak is a result of OpenGL: check your Android memory profiler, and if the part that keeps rising is “Graphics”, that means it’s an OpenGL issue.

General coding performance — do less, exit faster, and cache results

What to do?

  • Exit functions quickly
  • Optimize exit conditions (A && B != B && A for performance)
  • Cache results

Why?

In the end, you will have performance issues in your own code. You probably know that turning an O(n²) into 2 O(n) functions plus a Hashmap is a good idea, but that’s just the beginning.

Remember rule 0 — this is only for functions that you KNOW are taking a long time!

For starters, if a function has any exit criteria, you should be checking those first, by order of speed. Saved booleans first, followed by int comparisons, hashsets/string comparisons, and only then running heavier functions. Remember that Boolean terms are evaluated sequentially, and only if needed — so changing the order of if (A && B) to if (B && A) can save a surprising amount of time if A is slower than B!

If you’re working with lists, you should probably be using Sequences as a rule of thumb, not only when you know you need to, simply because it’s so easy to do. This allows lazy evaluation of collections without saving the intermediate results, which isn’t only more memory-efficient — memory allocation also means time, and especially on Android this can be considerable!

Caching results means you only need to run the heavy part once. That ‘heavy’ part can be as simple and innocuous as a String comparison — if your profiling reveals that that’s a heavy part, then it’s important!

If you’re running functions on objects whose properties don’t change, or change very infrequently, you can very easily add a backing field in the form of a lazy evaluated value:

fun checkSomething():Boolean{
return doSomeStuff()
}

to

internal val checkSomethingCachedValue by lazy { doSomeStuff() }
fun checkSomething():Boolean = checkSomethingCachedValue

This means you don’t need to change any references, and also have an easy way out if in the future you discover that the permanent value isn’t as permanent as you thought.

As time goes on, I find that I’ve already grabbed all the low-hanging fruit — but existing quickly and caching results is the gift that keeps on giving, even 5 years in.

Happy hunting!

If you found this interesting, check out my post on making mod-friendly games

--

--

Yair Morgenstern

Creator of Unciv, an open-source multiplatform reimplementation of Civ V