Asset management utilities using coroutines to load assets asynchronously.
While libGDX provides an AssetManager
class for loading and managing assets, even with KTX extensions
it is not fully compatible with Kotlin concurrency model based on coroutines due to thread blocking. AssetManager
does support asynchronous asset loading, but it uses only a single thread for asynchronous operations and achieves
its thread safety by synchronizing all of its methods. To achieve truly multi-threaded loading with multiple threads
for asynchronous loading, multiple manager instances must be maintained. Additionally, its API relies on polling -
managers have to be repeatedly updated on a specific (rendering) thread until the assets are loaded.
This KTX module offers an AssetManager
alternative - AssetStorage
. It leverages Kotlin coroutines
for asynchronous operations. It ensures thread safety by using a single non-blocking Mutex
for
a minimal set of operations mutating its state, while supporting truly multi-threaded asset loading
on any CoroutineContext
.
Feature | KTX AssetStorage |
libGDX AssetManager |
---|---|---|
Asynchronous loading | Supported. Loading operations that can be done asynchronously are performed in the chosen coroutine context. Parts that require OpenGL context are performed on the main rendering thread. | Supported. Loading that can be performed asynchronously is done on a dedicated thread, with necessary operations executed on the main rendering thread. |
Synchronous loading | Supported. loadSync blocks the current thread until a selected asset is loaded. A blocking coroutine can also be launched to load selected assets eagerly, but it cannot block the rendering thread or all loader threads to prevent from deadlocks. |
Limited. finishLoading method can be used to block the thread until the asset is loaded, but since it has no effect on loading order, it might block the thread until some or all unselected assets are loaded first. |
Thread safety | Excellent. Uses ktx-async threading model based on coroutines. Executes blocking IO operations in a separate coroutine context and, when necessary, finishes loading on the main rendering thread. The same asset, or assets with same dependencies, can be safely scheduled for loading by multiple coroutines concurrently. Multithreaded coroutine context can be used for asynchronous loading, possibly achieving loading performance boost. Concurrent AssetStorage usage is tested extensively by unit tests. |
Good. Achieved through synchronizing most methods, which unfortunately blocks the threads that use the manager. Thread blocking might affect application performance, especially since even the basic get method is synchronized after the loading is finished. Some operations, such as update or finishLoading , must be called from specific threads (i.e., the rendering thread). |
Concurrency | Supported. Multiple asset loading coroutines can be launched in parallel. Coroutine context used for asynchronous loading can have multiple threads that will be used concurrently. | Limited. update() loads assets one by one. AsyncExecutor with only a single thread is used internally by the AssetManager for asynchronous loading. To utilize more threads for asynchronous asset loading, multiple manager instances must be used. |
Loading order | Affected by the user. With suspending load , synchronous loadSync and Deferred -returning loadAsync , the user can have full control over asset loading order and parallelization. Selected assets can be loaded one after another within a single coroutine or in parallel with multiple coroutines, depending on the need. |
Unpredictable. If multiple assets are scheduled at once, it is difficult to reason about their loading order. finishLoading has no effect on loading order and instead blocks the thread until the selected asset is loaded. |
Exceptions | Customized. All expected issues are given separate exception classes with common root type for easier handling. Each loading issue can be handled differently, and each exception allows to pinpoint the issue. | Generic. Throws either GdxRuntimeException or a built-in Java runtime exception. Specific issues are difficult to handle separately. |
Error handling | Build-in language syntax. A regular try-catch block within coroutine body can be used to handle asynchronous loading errors. Provides a clean way to handle exceptions thrown by each asset separately. | Via listener. With caveats. A global error handling listener can be registered. While the listener will be called after a loading exception is thrown, it can point to a nested depedency rather than the actual scheduled asset. Flow of the application might be undisturbed, which makes it difficult to handle exceptions of specific assets. If an error listener is not registered, exceptions will be wrapped and rethrown by update . |
File name collisions | Multiple assets of different types can be loaded from same path. For example, a Texture and a Pixmap can both be loaded from the same PNG file. |
File paths act as unique identifiers. AssetManager cannot store multiple assets with the same path, even if they have different types. |
Progress tracking | Supported with caveats. AssetStorage does not force the users to schedule all assets for loading up front. To get the exact percent of loaded assets, all assets must be scheduled first (e.g., with loadAsync ). |
Supported. Since all loaded assets have to be scheduled up front, AssetManager can track total loading progress. However, AssetManager does not include dependencies in progress calculation, which can cause sudden jumps in values, and can become next to useless when loading assets with multiple depedencies such as exported scenes. |
Usage | Launch coroutine, load assets, use as soon as loaded. Asynchronous complexity is hidden by the coroutines. | Schedule loading, update in loop until loaded, extract from manager. API based on polling (are you done yet?), which might prove tedious during loading phase. Loading callbacks for individual assets are available, but have aan obscure API, and still require constant updating of the manager. |
This module also offers the AsyncAssetManager
- a thin wrapper of AssetManager
compatible with Kotlin coroutines.
Keep in mind that it still relies on the same thread-blocking concurrency model: its main feature is being able to await
for specific assets within coroutines, and it does not offer any performance improvements.
Here's an example of a simple application that loads three assets and switches to the next view, passing the loaded assets.
Implemented using libGDX AssetManager
:
class Application: ApplicationAdapter() {
private lateinit var assetManager: AssetManager
override fun create() {
assetManager = AssetManager().apply {
// Assets scheduled for loading up front. Notice no returns.
load("logo.png", Texture::class.java)
load("i18n.properties", I18NBundle::class.java)
load("ui.json", Skin::class.java)
}
}
override fun render() {
// Manager requires constant updating:
if (assetManager.update()) {
// When update() returns true, all scheduled assets are loaded:
finishLoading()
}
// Render loading prompt here.
// Other than performance impact of calling synchronized update() each frame,
// AssetManager does not block the rendering thread.
}
private fun finishLoading() {
assetManager.apply {
// Assets have to be retrieved from the manager manually:
val logo = get<Texture>("logo.png")
val bundle = get<I18NBundle>("i18n.properties")
val skin = get<Skin>("ui.json")
goToNextView(logo, bundle, skin)
}
}
private fun goToNextView(logo: Texture, bundle: I18NBundle, skin: Skin) { TODO() }
}
The same use case rewritten with KTX AssetStorage
:
class Application: ApplicationAdapter() {
override fun create() {
// ktx-async module requires initiating Kotlin coroutines context:
KtxAsync.initiate()
val assetStorage = AssetStorage()
// Launching asynchronous coroutine that will _not_ block the rendering thread:
KtxAsync.launch {
assetStorage.apply {
// Loading assets. Notice the immediate returns.
// The coroutine will suspend until each asset is loaded:
val logo = load<Texture>("logo.png")
val bundle = load<I18NBundle>("i18n.properties")
val skin = load<Skin>("ui.json")
// Assets are loaded and we already have references to all of them:
goToNextView(logo, bundle, skin)
}
}
}
override fun render() {
// Render loading prompt here.
// AssetStorage does not block the rendering thread and requires no updating.
}
private fun goToNextView(logo: Texture, bundle: I18NBundle, skin: Skin) { TODO() }
}
Without the polling-based AssetManager
API with its constant update()
calls and
non-returning load
, application using AssetStorage
is shorter and easier to read.
After the assets are loaded, AssetStorage
and AssetManager
behave more or less the same:
they store assets mapped by their file path that can be retrieved or disposed on demand.
In case of the AssetManager
, assets are uniquely identified by their paths; AssetStorage
identifies assets by their paths and types, i.e. you can load multiple assets with different
classes from the same file.
The key difference between KTX storage and libGDX manager is the threading model:
AssetManager
leverages only a single thread for asynchronous loading operations and ensures
thread safety by relying on the synchronized
methods,
while AssetStorage
can utilize any chosen number of threads specified by its coroutine
context and uses non-blocking coroutines to load the assets.
See ktx-async
setup section to enable coroutines in your project.
KtxAsync.initiate()
must be called on the main rendering thread before AssetStorage
is used.
AssetStorage
contains the following core methods:
get: T
- returns a loaded asset or throwsMissingAssetException
if the asset is unavailable.getOrNull: T?
- returns a loaded asset ornull
if the asset is unavailable.getAsync: Deferred<T>
- returns aDeferred
reference to the asset. Suspendingawait()
can be called to obtain the asset instance.isCompleted
can be used to check if the asset loading was finished.load: T
(suspending) - schedules asset for asynchronous loading. Suspends the coroutine until the asset is fully loaded. Resumes the coroutine and returns the asset once it is loaded.loadAsync: Deferred<T>
- schedules asset for asynchronous loading. Returns aDeferred
reference to the asset which will be completed after the loading is finished.loadSync: T
- blocks the current thread until the selected asset is loaded. Use only outside of coroutines for crucial assets that need to be loaded synchronously (e.g. loading screen assets).unload: Boolean
(suspending) - unloads the selected asset. If the asset is no longer referenced, it will be removed from the storage and disposed of. Suspends the coroutine until the asset is unloaded. Returnstrue
is the selected asset was present in storage orfalse
if the asset was absent.add
(suspending) - manually adds a fully loaded asset to storage. The storage will take care of disposing of the asset.dispose
(blocking and suspending variants available) - unloads all assets. Cancels all current loadings. Depending on the variant, will block the current thread or suspend the coroutine until all the assets are unloaded.
Additional asset management methods include:
isLoaded: Boolean
- checks if the selected asset is fully loaded.contains: Boolean
- checks if the selected asset is present in storage, loaded or not.progress
- allows to access loading progress data.getReferenceCount: Int
- checks how many times the asset was loaded, added or required as dependency by other assets. Returns 0 if the asset is not present in the storage.getDependencies: List<Identifier>
- returns list of dependencies of the selected asset. If the asset is not present in the storage, an empty list will be returned.getLoader: AssetLoader
- obtainsAssetLoader
instance for the given file.setLoader
- allows to associate a customAssetLoader
with the selected file and asset types.
AssetStorage
uniquely identifies assets by their path and Class
.
Since these values can be passed in 3 basic ways, most methods are available in 3 variants:
- Inlined, with reified type and
String
path parameter. - With
Identifier
parameter, which storesClass
andString
path of the asset. - With libGDX
AssetDescriptor
storingClass
,String
file name and loading data of the asset.
All three variants behave identically and are available for convenience.
If any asset data is missing from either String
path or Identifier
, additional parameters are available
to match the AssetDescriptor
API.
To ease the API usage, the following utilities are provided:
AssetStorage.getAssetDescriptor
- creates anAssetDescriptor
instance that has loading data of an asset.AssetStorage.getIdentifier
- creates anIdentifier
instance that uniquely identifies a stored asset.AssetDescriptor.toIdentifier
- converts anAssetDescriptor
to anIdentifier
.Identifier.toAssetDescriptor
- converts anIdentifier
to anAssetDescriptor
with optional loading parameters.
AssetStorage
throws exceptions extending the AssetStorageException
class. All of its subclasses are documented,
explaining when and why they are thrown.
Please refer to the sources documentation for further details.
Note that usage example require basic understanding of the ktx-async
module and Kotlin
coroutines.
Creating an AssetStorage
with default settings:
import ktx.assets.async.AssetStorage
import ktx.async.KtxAsync
fun create() {
// Necessary to initiate the coroutines context:
KtxAsync.initiate()
val assetStorage = AssetStorage()
}
Customizing an AssetStorage
. In this example a multi-threaded coroutine context
was assigned to storage, so the assets can be loaded in parallel on multiple threads:
import com.badlogic.gdx.assets.loaders.resolvers.InternalFileHandleResolver
import ktx.assets.async.AssetStorage
import ktx.async.KtxAsync
import ktx.async.newAsyncContext
fun create() {
KtxAsync.initiate()
val assetStorage = AssetStorage(
// Used to perform asynchronous file loading:
asyncContext = newAsyncContext(threads = 4),
// Used for resolving file paths:
fileResolver = InternalFileHandleResolver(),
// Whether to add standard libGDX loaders for common assets:
useDefaultLoaders = true
)
}
Loading assets using AssetStorage
:
import com.badlogic.gdx.graphics.Texture
import kotlinx.coroutines.launch
import ktx.assets.async.AssetStorage
import ktx.async.KtxAsync
fun loadAsset(assetStorage: AssetStorage) {
// Launching a coroutine to load the assets asynchronously:
KtxAsync.launch {
// This will suspend the coroutine until the texture is loaded:
val texture = assetStorage.load<Texture>("images/logo.png")
// Now the coroutine resumes and the texture can be used.
}
}
Loading assets with customized loading parameters:
import com.badlogic.gdx.assets.loaders.TextureLoader.TextureParameter
import com.badlogic.gdx.graphics.Texture
import kotlinx.coroutines.launch
import ktx.assets.async.AssetStorage
import ktx.async.KtxAsync
fun loadAsset(assetStorage: AssetStorage) {
KtxAsync.launch {
// You can optionally specify loading parameters for each asset.
// AssetStorage reuses default libGDX asset loaders and their
// parameters classes.
val texture = assetStorage.load<Texture>(
path = "images/logo.png",
parameters = TextureParameter().apply {
genMipMaps = true
}
)
// Now the texture is loaded and can be used.
}
}
Loading assets sequentially using AssetStorage
:
import com.badlogic.gdx.graphics.Texture
import kotlinx.coroutines.launch
import ktx.assets.async.AssetStorage
import ktx.async.KtxAsync
fun loadAssets(assetStorage: AssetStorage) {
// Unlike AssetManager, AssetStorage allows to easily control the order of loading.
KtxAsync.launch {
// This will suspend the coroutine until the texture is loaded:
val logo = assetStorage.load<Texture>("images/logo.png")
// After the first image is loaded,
// the coroutine resumes and loads the second asset:
val background = assetStorage.load<Texture>("images/background.png")
// Now both images are loaded and can be used.
}
}
Loading assets asynchronously:
import com.badlogic.gdx.graphics.Texture
import com.badlogic.gdx.graphics.g2d.BitmapFont
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import ktx.assets.async.AssetStorage
import ktx.async.KtxAsync
fun loadAssets(assetStorage: AssetStorage) {
// You can also schedule the assets for asynchronous loading
// without suspending the coroutine.
KtxAsync.launch {
// Launching asynchronous asset loading with Kotlin's built-in `async`:
val texture = async { assetStorage.load<Texture>("images/logo.png") }
val font = async { assetStorage.load<BitmapFont>("fonts/font.fnt") }
// Suspending the coroutine until both assets are loaded:
doSomethingWithTextureAndFont(texture.await(), font.await())
}
}
Loading assets in parallel:
import com.badlogic.gdx.graphics.Texture
import com.badlogic.gdx.graphics.g2d.BitmapFont
import kotlinx.coroutines.launch
import ktx.assets.async.AssetStorage
import ktx.async.KtxAsync
import ktx.async.newAsyncContext
fun loadAssets() {
// Using Kotlin's `async` will ensure that the coroutine is not
// immediately suspended and assets are scheduled asynchronously,
// but to take advantage of really parallel asset loading, we have
// to pass a context with multiple loading threads to AssetStorage:
val assetStorage = AssetStorage(asyncContext = newAsyncContext(threads = 2))
// Instead of using Kotlin's built-in `async`, you can also use
// the `loadAsync` method of AssetStorage which is a shortcut for
// `async(assetStorage.asyncContext) { assetStorage.load }`:
val texture = assetStorage.loadAsync<Texture>("images/logo.png")
val font = assetStorage.loadAsync<BitmapFont>("fonts/font.fnt")
// Now both assets will be loaded asynchronously, in parallel.
KtxAsync.launch {
// Suspending the coroutine until both assets are loaded:
doSomethingWithTextureAndFont(texture.await(), font.await())
}
}
Unloading assets from AssetStorage
:
import com.badlogic.gdx.graphics.Texture
import kotlinx.coroutines.launch
import ktx.assets.async.AssetStorage
import ktx.async.KtxAsync
fun unloadAsset(assetStorage: AssetStorage) {
KtxAsync.launch {
// Suspends the coroutine until the asset is unloaded:
assetStorage.unload<Texture>("images/logo.png")
// When the coroutine resumes here, the asset is unloaded.
// If no other assets use it as dependency, it will
// be removed from the asset storage and disposed of.
// Note that you can also do this asynchronously if you don't
// want to suspend the coroutine:
async { assetStorage.unload<Texture>("images/logo.png") }
}
}
Accessing assets from AssetStorage
:
import com.badlogic.gdx.graphics.Texture
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.launch
import ktx.assets.async.AssetStorage
import ktx.async.KtxAsync
fun accessAsset(assetStorage: AssetStorage) {
// Typically, you can simply use assets returned by `load`,
// but AssetStorage also allows you to access assets
// already loaded by other coroutines.
// Immediately returns loaded asset or throws an exception if missing:
var texture = assetStorage.get<Texture>("images/logo.png")
// If you specify the variable type, you can use the braces operator as well:
val thisIsA: Texture = assetStorage["images/logo.png"]
// Immediately returns loaded asset or returns null if missing:
val textureOrNull = assetStorage.getOrNull<Texture>("images/logo.png")
KtxAsync.launch {
// There is also a special way to access your assets within coroutines
// when you need to wait until they are loaded asynchronously.
// When calling `getAsync`, AssetStorage will not throw an exception
// or return null if the asset is still loading. Instead, it will
// return a Kotlin Deferred reference. This allows you suspend the
// coroutine until the asset is loaded:
val asset: Deferred<Texture> = assetStorage.getAsync("images/logo.png")
// Checking if the asset loading has finished:
asset.isCompleted
// Suspending the coroutine to obtain asset instance:
texture = asset.await()
// If you want to suspend the coroutine to wait for the asset,
// you can do this in a single line:
texture = assetStorage.getAsync<Texture>("images/logo.png").await()
// Now the coroutine is resumed and `texture` can be used.
}
}
Accessing additional data about an asset:
import com.badlogic.gdx.graphics.Texture
import ktx.assets.async.AssetStorage
fun inspectAsset(assetStorage: AssetStorage) {
// Returns true if asset is in the storage, loaded or not:
assetStorage.contains<Texture>("images/logo.png")
// Returns true if the asset loading has finished:
assetStorage.isLoaded<Texture>("images/logo.png")
// Checks how many times the asset was loaded or used as a dependency:
assetStorage.getReferenceCount<Texture>("images/logo.png")
// Returns a list of dependencies loaded along with the asset:
assetStorage.getDependencies<Texture>("images/logo.png")
}
Adding a fully loaded asset manually to the AssetStorage
:
import com.badlogic.gdx.graphics.g2d.Batch
import com.badlogic.gdx.graphics.g2d.SpriteBatch
import kotlinx.coroutines.launch
import ktx.assets.async.AssetStorage
import ktx.async.KtxAsync
fun addAsset(assetStorage: AssetStorage) {
KtxAsync.launch {
// You can store arbitrary objects in AssetStorage.
// They will be marked as loaded and accessible with `get`.
// If they are disposable, calling `unload` will also
// dispose of these assets. This might be useful for
// expensive objects such as the Batch.
val batch: Batch = SpriteBatch()
// Suspending the coroutine until the `batch` is added:
assetStorage.add("batch", batch)
// Now our `batch` will be available under "batch" path
// and `Batch` class.
}
}
Loading assets synchronously with AssetStorage
:
import com.badlogic.gdx.graphics.g2d.BitmapFont
// Sometimes you need to load assets immediately and coroutines just get in the way.
// A common example of this would be getting the assets for the loading screen.
// In this case you can use `loadSync`, which will block the current thread until
// the asset is loaded:
val font = assetStorage.loadSync<BitmapFont>("com/badlogic/gdx/utils/lsans-15.fnt")
// Note that you should not use `loadSync` from within coroutines.
// Whenever possible, prefer `load` or `loadAsync`. Try not to mix synchronous and
// asynchronous loading, especially on the same assets or assets with same dependencies.
Disposing of all assets stored by AssetStorage
:
// Will block the current thread to unload all assets:
assetStorage.dispose()
// This will also disrupt loading of all unloaded assets.
// Disposing errors are logged by default, and do not stop the process.
Disposing of all assets asynchronously:
import com.badlogic.gdx.Gdx
import kotlinx.coroutines.launch
import ktx.assets.async.AssetStorage
import ktx.async.KtxAsync
fun unloadAllAssets(assetStorage: AssetStorage) {
KtxAsync.launch {
// Suspends the coroutine until all assets are disposed of:
assetStorage.dispose { identifier, exception ->
// This lambda will be invoked for each encountered disposing error:
Gdx.app.error("KTX", "Unable to dispose of asset: $identifier", exception)
}
}
}
Loading assets with custom error handling:
import com.badlogic.gdx.graphics.Texture
import kotlinx.coroutines.launch
import ktx.assets.async.AssetLoadingException
import ktx.assets.async.AssetStorage
import ktx.assets.async.AssetStorageException
import ktx.async.KtxAsync
fun loadAsset(assetStorage: AssetStorage) {
KtxAsync.launch {
// You can handle loading errors with a classic try-catch block:
try {
val texture = assetStorage.load<Texture>("images/logo.png")
} catch (exception: AssetLoadingException) {
// Asset loader threw an exception - unable to load the asset.
} catch (exception: AssetStorageException) {
// Another error occurred. See AssetStorageException subclasses.
}
// Note that if the asset loading ended with an exception,
// the same exception will be rethrown each time the asset
// is accessed with `get`, `getOrNull`, `getAsync.await` or `load`.
}
}
Tracking loading progress:
import ktx.assets.async.AssetStorage
fun trackProgress(assetStorage: AssetStorage) {
// Current loading progress can be tracked with the `progress` property:
val progress = assetStorage.progress
// Total number of scheduled assets:
progress.total
// Total number of loaded assets:
progress.loaded
// Current progress percentage in [0, 1]:
progress.percent
// Checks if all scheduled assets are loaded:
progress.isFinished
// Remember that due to the asynchronous nature of AssetStorage,
// the progress is only _eventually consistent_ with the storage.
// It will not know about the assets that are not fully scheduled
// for loading yet. Use progress for display only and base your
// application logic on coroutine callbacks instead.
}
Adding a custom AssetLoader
to AssetStorage
:
import ktx.assets.async.AssetStorage
fun createCustomAssetStorage(): AssetStorage {
val assetStorage = AssetStorage()
// Custom asset loaders should be added before loading any assets:
assetStorage.setLoader(suffix = ".file.extension") {
MyCustomAssetLoader(assetStorage.fileResolver)
}
// Remember to extend one of:
// com.badlogic.gdx.assets.loaders.SynchronousAssetLoader
// com.badlogic.gdx.assets.loaders.AsynchronousAssetLoader
return assetStorage
}
It is completely safe to call load
and loadAsync
multiple times with the same asset data, even just to obtain
asset instances. In that sense, they can be used as an alternative to getAsync
inside coroutines.
Instead of loading the same asset multiple times, AssetStorage
will just increase the count of references
to the asset and return the same instance on each request. This also works concurrently - the storage will
always load just one asset instance, regardless of how many threads and coroutines called load
in parallel.
However, to eventually unload the asset, you have to call unload
the same number of times as load
/loadAsync
,
or simply dispose of all assets with dispose
, which clears all reference counts and unloads everything
from the storage.
Unlike load
and loadAsync
, add
should be called only once on a single asset, and when it is not stored
in the storage. You cannot add existing assets to the storage, even if they were previously loaded by it.
This is because if we are not sure that AssetStorage
handled the loading (or creation) of an object, tracking
its dependencies and lifecycle is difficult and left to the user. Trying to add
an asset with existing path
and type will not increase its reference count, and will throw an exception instead.
Adding assets that were previously loaded by the AssetStorage
under different paths is also a misuse of the API
which might result in unloading the asset or its dependencies prematurely. True aliases are currently unsupported.
loadSync
can also be used multiple times on the same asset in addition to load
and loadAsync
, but keep in mind
that mixing synchronous and asynchronous loading can cause loading exceptions. In particular, loadSync
will not wait
for asset dependencies loaded by other asynchronous coroutines and will raise an exception instead. As a rule of thumb,
it is best not to mix synchronous and asynchronous loading of the same asset or assets with the same dependencies.
Loading method | Return type | Suspending | Loading type | When to use |
---|---|---|---|---|
load |
Asset |
Yes | Asynchronous. Schedules asset loading and awaits for the loaded asset, suspending the coroutine. | Within coroutines for controlled, sequential loading. |
loadAsync |
Deferred<Asset> |
No, but can be with Deferred.await |
Asynchronous. Schedules asset loading and returns a Deferred reference to the asset. |
Outside of coroutines: for scheduling of assets that can be loaded non-sequentially and in parallel. Within coroutines: for controlled order of loading with Deferred.await . |
loadSync |
Asset |
No | Synchronous. Blocks the current thread until an asset is loaded. Loading is performed on the rendering thread. | Only outside of coroutines, for crucial assets that need to be loaded synchronously (e.g. loading screen assets). |
Asset
is the generic type of the loaded asset.
- Avoid mixing concurrent synchronous (
loadSync
) and asynchronous (load
,loadAsync
) loading of the same asset, or assets with the same dependencies. - Prefer asynchronous (
load
,loadAsync
) asset loading methods whenever possible due to better performance and compatibility with coroutines. - Note that
loadSync
is not necessary to load initial assets. You launch a coroutine withKtxAsync.launch
, load the assets sequentially withload
or in parallel withloadAsync
/await
, and then switch to the first view that uses the assets once they are loaded. Loading assets asynchronously might be faster, especially when done in parallel - but ultimately it comes down to personal preference and code maintainability.
Kotlin's runBlocking
function allows to launch a coroutine and block the current thread until the coroutine
is finished. In general, you should avoid runBlocking
calls from the main rendering thread or the threads
assigned to AssetStorage
for asynchronous loading.
This simple example will cause a deadlock:
import com.badlogic.gdx.ApplicationAdapter
import com.badlogic.gdx.backends.lwjgl.LwjglApplication
import com.badlogic.gdx.graphics.Texture
import kotlinx.coroutines.runBlocking
import ktx.assets.async.AssetStorage
import ktx.async.KtxAsync
fun main() {
LwjglApplication(App())
}
class App : ApplicationAdapter() {
override fun create() {
KtxAsync.initiate()
val assetStorage = AssetStorage()
runBlocking { // <- !!! Do NOT do this. !!!
assetStorage.load<Texture>("images/logo.png")
}
println("Will never be printed.")
}
}
This is because AssetStorage
needs access to the main rendering thread to finish loading the Texture
with OpenGL context, but we have blocked the main rendering thread to wait for the asset.
In a similar manner, this example blocks the only thread assigned to AssetStorage
for asynchronous operations:
import com.badlogic.gdx.ApplicationAdapter
import com.badlogic.gdx.backends.lwjgl.LwjglApplication
import com.badlogic.gdx.graphics.Texture
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import ktx.assets.async.AssetStorage
import ktx.async.KtxAsync
import ktx.async.newSingleThreadAsyncContext
fun main() {
LwjglApplication(App())
}
class App : ApplicationAdapter() {
override fun create() {
KtxAsync.initiate()
val asyncContext = newSingleThreadAsyncContext()
val assetStorage = AssetStorage(asyncContext = asyncContext)
// Launching coroutine on the storage thread:
KtxAsync.launch(asyncContext) {
runBlocking { // <- !!! Do NOT do this. !!!
assetStorage.load<Texture>("images/logo.png")
}
println("Will never be printed and AssetStorage will be unusable.")
}
}
}
As a rule of thumb, you should use suspending AssetStorage
methods only from non-blocking coroutines, e.g. those
launched with KtxAsync.launch
. If you change runBlocking
to a proper coroutine launch in either of the examples,
you will notice that the deadlocks no longer occur.
It does not mean that runBlocking
will always cause a deadlock, however. You can safely use runBlocking
:
- For
dispose
, both suspending and non-suspending variants. - For all non-suspending methods such as
get
,getOrNull
,contains
,isLoaded
,setLoader
,getLoader
. - For
add
. Whileadd
does suspend the coroutine, it requires neither the rendering thread nor the loading threads. - For
load
andgetAsync.await
calls requesting already loaded assets. Use with caution. - From within other threads than the main rendering thread and the
AssetStorage
loading threads. These threads will be blocked until the operation is finished, which is not ideal, but at least the loading will remain possible.
Consider this typical application using AssetManager
:
import com.badlogic.gdx.ApplicationAdapter
import com.badlogic.gdx.assets.AssetManager
import com.badlogic.gdx.graphics.Texture
import com.badlogic.gdx.graphics.g2d.BitmapFont
class WithAssetManager: ApplicationAdapter() {
private lateinit var assetManager: AssetManager
override fun create() {
assetManager = AssetManager()
// Scheduling assets for asynchronous loading:
assetManager.load("images/logo.png", Texture::class.java)
assetManager.load("com/badlogic/gdx/utils/lsans-15.fnt", BitmapFont::class.java)
}
override fun render() {
// Manager has to be constantly updated until the assets are loaded:
if (assetManager.update()) {
// Now the assets are loaded:
changeView()
}
// Render loading prompt.
}
private fun changeView() {
val texture: Texture = assetManager["images/logo.png"]
TODO("Now the assets are loaded and can be accessed with $assetManager.get!")
}
}
Since usually applications have more than just 2 assets, many developers choose to treat AssetManager
as
a container of assets: a map with file paths as keys and loaded assets are values. You typically load most
or all assets at the very beginning while showing the loading screen, and then just use AssetManager.get(path)
to obtain the assets after they are loaded.
However, this approach has some inconveniences and problems:
- The API is not very idiomatic to Kotlin, but in this particular case ktx-assets can help.
update
has to be called on rendering thread during loading.- If you forget to stop updating the manager after the assets are loaded, the initiation code (such as
changeView
in our example) can be executed multiple times. If you replaceTODO
withprintln
in the example above, you wil notice thatchangeView
is invoked on everyrender
after the loading is finished. - The majority of
AssetManager
methods aresynchronized
, which means they block the thread that they are executed in and are usually more expensive to call than regular methods. This includes theget
method, which does not change the internal state of the manager at all. Even if the assets are fully loaded and you no longer modify theAssetManager
state, you still pay the cost of synchronization. This is especially relevant if you use multiple threads, as they can slow each other down, both waiting for the lock. AssetManager
stores assets mapped only by their paths.manager.get<Texture>(path)
andmanager.get<Pixmap>(path)
are both valid method calls that will throw a runtime class cast exception.
AssetStorage
avoids most of these problems.
Similarly to AssetManager
, AssetStorage
stores and allows you to get
your loaded assets, so if you want to
migrate from AssetManager
to AssetStorage
, all you have to change initially is the loading code:
import com.badlogic.gdx.ApplicationAdapter
import com.badlogic.gdx.graphics.Texture
import com.badlogic.gdx.graphics.g2d.BitmapFont
import ktx.assets.async.AssetStorage
import ktx.async.KtxAsync
import ktx.async.newAsyncContext
class WithAssetStorageBasic: ApplicationAdapter() {
// Using multiple threads to load the assets in parallel:
private val assetStorage = AssetStorage(newAsyncContext(threads = 2))
override fun create() {
KtxAsync.initiate()
// Scheduling assets for asynchronous loading:
assetStorage.loadAsync<Texture>("images/logo.png")
assetStorage.loadAsync<BitmapFont>("com/badlogic/gdx/utils/lsans-15.fnt")
}
override fun render() {
// The closest alternative to `update` would be to check the progress on each render:
if (assetStorage.progress.isFinished) {
changeView()
}
}
private fun changeView() {
val texture: Texture = assetStorage["images/logo.png"]
TODO("Now the assets are loaded and can be accessed with $assetStorage.get!")
}
}
As you can see, after the assets are loaded, the API of both AssetManager
and AssetStorage
is very similar.
While this example might seem almost identical to the AssetManager
code, you already get the benefit of parallel
loading and using coroutines under the hood.
Now, this approach might just work in most cases, but there are still two things we should address:
- You will notice that your IDE warns you about not using the results of
loadAsync
which returnDeferred
instances. This means we're launching asynchronous coroutines and ignore their results. AssetStorage.progress
should be used only for display and debugging. You generally should not base your application logic onprogress
, as it is only eventually consistent with theAssetStorage
state.
Let's rewrite it again - this time with proper coroutines:
import com.badlogic.gdx.ApplicationAdapter
import com.badlogic.gdx.graphics.Texture
import com.badlogic.gdx.graphics.g2d.BitmapFont
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import ktx.assets.async.AssetStorage
import ktx.async.KtxAsync
import ktx.async.newAsyncContext
class WithAssetStorage: ApplicationAdapter() {
// Using multiple threads to load the assets in parallel:
private val assetStorage = AssetStorage(newAsyncContext(threads = 2))
override fun create() {
KtxAsync.initiate()
// Scheduling assets for asynchronous loading:
val assets = listOf(
assetStorage.loadAsync<Texture>("images/logo.png"),
assetStorage.loadAsync<BitmapFont>("com/badlogic/gdx/utils/lsans-15.fnt")
)
// Instead of constantly updating or checking the progress,
// we're launching a coroutine that "waits" for the assets:
KtxAsync.launch {
// Suspending coroutine until all assets are loaded:
assets.joinAll()
// Resuming! Now the assets are loaded, and we can obtain them with `get`:
changeView()
}
}
private fun changeView() {
val texture: Texture = assetStorage["images/logo.png"]
TODO("Now the assets are loaded and can be accessed with $assetStorage.get!")
}
}
The IDE no longer warns us about ignoring Deferred
results and we no longer have to check the progress all the time.
Instead, we suspend a coroutine to "wait" for the assets and run our initiation code once the assets are loaded.
The code using AssetStorage
is not necessarily shorter in this case, but:
- You get the performance improvements of loading assets in parallel.
AssetStorage
does not have to be updated on render.- Your code is reactive and
changeView
is guaranteed to be called only once as soon as the assets are loaded. AssetStorage.get
is non-blocking and faster thanAssetManager.get
.AssetStorage
does a better job of storing your assets efficiently after the loading is finished.AssetStorage
stores assets mapped by their path and type. You will not have to deal with class cast exceptions.- You can easily expand the launched coroutine or integrate more coroutines into your application later for other asynchronous operations.
Besides, you get the additional benefits of other AssetStorage
features and methods described in this file.
Closest equivalents in AssetManager
and AssetStorage
APIs:
AssetManager |
AssetStorage |
Note |
---|---|---|
get<T>(String) |
get<T>(String) |
AssetStorage uses reified types to prevent from runtime class cast exceptions. |
get(String, Class<T>) |
get(Identifier<T>) |
Identifier pairs file path and asset type to identify an asset. |
get(AssetDescriptor<T>) |
get(AssetDescriptor<T>) |
|
load(String, Class<T>) |
loadAsync<T>(String) |
load<T>(String) can also be used as an alternative within coroutines. |
load(String, Class<T>, AssetLoaderParameters<T>) |
loadAsync<T>(String, AssetLoaderParameters<T>) |
load<T>(String, AssetLoaderParameters<T>) can also be used as an alternative within coroutines. |
load(AssetDescriptor) |
loadAsync(AssetDescriptor) |
load(AssetDescriptor) can also be used as an alternative within coroutines. |
isLoaded(String) |
isLoaded<T>(String) |
AssetStorage requires asset type, so the method is generic. |
isLoaded(String, Class) |
isLoaded(Identifier) |
|
isLoaded(AssetDescriptor) |
isLoaded(AssetDescriptor) |
|
unload(String) |
unload<T>(String) , unload(Identifier) |
AssetStorage requires asset type, so the methods are generic. |
getProgress() |
progress.percent |
|
isFinished() |
progress.isFinished |
|
update() , update(Int) |
N/A | AssetStorage does not need to be updated. Rely on coroutines to execute code when the assets are loaded or use progress.isFinished . |
finishLoadingAsset(String) |
loadSync<T>(String) |
Assets that need to be loaded immediately (e.g. loading screen assets) can be loaded with loadSync instead of asynchronous load or loadAsync for convenience. |
finishLoadingAsset(AssetDescriptor) |
loadSync(AssetDescriptor) |
|
finishLoading() |
N/A | AssetStorage does not provide methods that block the thread until all assets are loaded. Rely on progress.isFinished instead. |
addAsset(String, Class<T>, T) |
add<T>(String, T) |
|
contains(String) |
contains<T>(String) , contains(Identifier) |
AssetStorage requires asset type, so the methods are generic. |
getDiagnostics |
takeSnapshot , takeSnapshotAsync |
Returns a copy of the internal state. Returned AssetStorageSnapshot instance provides a prettyPrint method with formatted output. |
setErrorHandler |
N/A, try-catch |
With AssetStorage you can handle loading errors immediately with regular built-in try-catch syntax. Error listener is not required. |
clear() |
dispose() |
AssetStorage.dispose will not kill AssetStorage threads and can be safely used multiple times like AssetManager.clear . |
dispose() |
dispose() |
AssetStorage also provides a suspending variant with custom error handling. |
AssetStorage
does its best to integrate with libGDX APIs - including the AssetLoader
implementations, which were
designed for the AssetManager
. A dedicated internal wrapper
extends and overrides AssetManager
, delegating a subset of supported methods to AssetStorage
. The official
AssetLoader
implementations use supported methods such as get
, but please note that some third-party loaders might
not work out of the box with AssetStorage
. Exceptions related to broken loaders include UnsupportedMethodException
and MissingDependencyException
.
AssetStorage
, even with its wrapper, cannot be used as drop-in replacement for AssetManager
throughout the
official APIs. In particular, Texture.setAssetManager
and Cubemap.setAssetManager
are both unsupported.
If you heavily rely on these unsupported APIs or custom asset loaders, you might need to use AsyncAssetManager
instead.
While ktx-assets
module does provide some extensions to the AssetManager
, which is a direct
alternative to the AssetStorage
, this module's other utilities for libGDX assets and files APIs might still
prove useful.
AsyncAssetManager
is an extension and a drop-in replacement of the classic AssetManager
. In addition to standard
asset loading and management features, it offers asynchronous asset loading methods returning Deferred
instances
compatible with Kotlin coroutines. Assets wrapped with Deferred
allow easy access to the loaded asset instances via
awaiting, as well as fine-grained error handling.
Pros of AsyncAssetManager
over AssetManager
:
+
Integrates with Kotlin coroutines.+
Eases obtaining references to the loaded assets.+
Allows waiting for specific assets withDeferred.await
without blocking any threads or callingfinishLoading
.+
Improves handling of asset loading errors.+
Does not throw exceptions when callingupdate()
if the associated asset or its dependency was scheduled for asynchronous loading. Instead, the exception will be rethrown by theDeferred
instance. This allows to load problematic or external assets with precise error handling.
Cons of AsyncAssetManager
:
-
AsyncAssetManager
still relies on the same concurrency model with blockingsynchronized
methods, which are not fully compatible with coroutines. It lacks most of the asynchronous and non-blocking utilities ofAssetStorage
.-
Handling of theDeferred
callbacks adds some overhead both in terms of the loading performance and garbage collection. As a rule of thumb, assets that do not require theDeferred
API features or robust error handling can be loaded with the defaultload
method if asset loading performance is a concern.-
Fine-grained error handling implementation required quite inefficient dependencies search due to the lack of necessary methods in the originalAssetManager
. Although it only affects the performance of the application in case of asset loading errors which normally should not occur in production, it does impact the overall performance of the manager.-
Using custom or third-party asset loaders requires additional setup withsetLoaderParameterSupplier
.
In general, the AssetStorage
is advised over the AsyncAssetManager
, as it was designed to be entirely non-blocking,
and fully compatible with coroutines. It offers superior performance and better coroutines support. AsyncAssetManager
should be used instead only as an intermediate step during migration from an AssetManager
to the AssetStorage
, or
if an AssetManager
is strictly required by an otherwise incompatible third-party API.
AsyncAssetManager
supports all features of the AssetManager
.
The main difference is that loadAsync
should be used over the standard load
methods whenever you need a reference to
the loaded asset or need full control over its error handling.
/* AssetManager */
// Standard asynchronous loading. Returns nothing.
assetManager.load<Texture>("image.png")
// Asset can be obtained from the manager after it is loaded:
val texture = assetManager.get<Texture>("image.png")
/* AsyncAssetManager */
// Asynchronous loading with callback - returns a Deferred<Texture> instance:
val reference = assetManager.loadAsync<Texture>("image.png")
// Waiting for the asset inside a coroutine - await() returns Texture:
KtxAsync.launch { val texture = reference.await() }
// The texture can still be obtained from the manager after it is loaded:
val texture = assetManager.get<Texture>("image.png")
An example of a simple application migrated from AssetManager
to AsyncAssetManager
:
// Before:
class ApplicationWithAssetManager: ApplicationAdapter() {
private lateinit var assetManager: AssetManager
var assetsLoaded = false
override fun create() {
assetManager = AssetManager()
// Assets scheduled for loading up front. Notice no returns.
assetManager.load<Texture>("logo.png")
assetManager.load<Skin>("ui.json")
}
override fun render() {
if (assetManager.update() && !assetsLoaded) {
// When update() returns true, all scheduled assets are loaded:
finishLoading()
// We have to use an additional flag to avoid calling finishLoading
// multiple times, as update() will keep on returning true:
assetsLoaded = true
}
}
private fun finishLoading() {
// Assets have to be retrieved from the manager manually:
val logo = assetManager.get<Texture>("logo.png")
val skin = assetManager.get<Skin>("ui.json")
goToNextView(logo, skin)
}
private fun goToNextView(logo: Texture, skin: Skin) { TODO() }
}
// After:
class ApplicationWithAsyncAssetManager: ApplicationAdapter() {
private lateinit var assetManager: AsyncAssetManager
override fun create() {
KtxAsync.initiate()
assetManager = AsyncAssetManager()
// Assets scheduled for loading up front returning Deferred instances:
val logo = assetManager.loadAsync<Texture>("logo.png")
val ui = assetManager.loadAsync<Skin>("ui.json")
// Launching a coroutine that waits for the assets:
KtxAsync.launch {
// The code ran after the assets are loaded is greatly simplified
// and no longer needs an additional flag to run just once:
goToNextView(logo.await(), ui.await())
}
}
override fun render() {
// The manager still has to be updated, but the render code is simplified.
assetManager.update()
}
private fun goToNextView(logo: Texture, skin: Skin) { TODO() }
}
Scheduling an asset for asynchronous loading:
import com.badlogic.gdx.graphics.Texture
import kotlinx.coroutines.Deferred
import ktx.assets.async.AsyncAssetManager
fun loadAsset(): Deferred<Texture> {
val assetManager = AsyncAssetManager()
return assetManager.loadAsync<Texture>("image.png")
}
Waiting for a scheduled asset:
import com.badlogic.gdx.graphics.Texture
import kotlinx.coroutines.launch
import ktx.assets.async.AsyncAssetManager
import ktx.async.KtxAsync
fun waitForAsset() {
val assetManager = AsyncAssetManager()
val asset = assetManager.loadAsync<Texture>("image.png")
KtxAsync.launch {
val texture = asset.await()
TODO("Do something with the loaded texture.")
}
// Note that the manager still has to be updated to load the asset.
}
Handling asset loading exceptions:
fun handleLoadingError() {
val assetManager = AsyncAssetManager()
val asset = assetManager.loadAsync<TextureAtlas>("my.atlas")
KtxAsync.launch {
try {
val atlas = asset.await()
} catch (exception: DependencyLoadingException) {
Gdx.app.error("KTX", "Dependency failed to load!", exception)
} catch (exception: Exception) {
Gdx.app.error("KTX", "Cannot load the asset!", exception)
}
}
}
Registering a custom asset loader:
import ktx.assets.TextAssetLoader
import ktx.assets.async.AsyncAssetManager
import ktx.assets.setLoader
fun registerCustomAssetLoader() {
val assetManager = AsyncAssetManager()
assetManager.setLoader(TextAssetLoader())
// AsyncAssetManager uses AssetLoaderParameters to support callbacks.
// When you register a custom asset loader, the manager needs to know
// how to construct its loading parameters:
assetManager.setLoaderParameterSupplier<TextAssetLoader> {
TextAssetLoader.TextAssetLoaderParameters()
}
}
The ktx-assets
module provides some general utilities for AssetManager
which also work with the
AsyncAssetManager
extension.
There seem to be no other coroutines-based asset loaders available.
However, libGDX AssetManager
is still viable when efficient parallel loading is not a requirement.
Alternatives to the AssetStorage
and AsyncAssetManager
include:
- Using
AssetManager
directly. - Using
ktx-assets
extensions forAssetManager
. AnnotationAssetManager
fromlibgdx-utils
that extendsAssetManager
and allows specifying assets for loading by marking fields with annotations.- Loading assets manually without a manager.
ktx-async
module, which is used extensively by this extension.- Official libGDX
AssetManager
article.