Concurrency and multi-threading
Here we talk about OPENRNDR’s primitives for concurrency.
The largest complication in multi-threading in OPENRNDR lies in how the underlying graphics API (OpenGL) can only be used from threads on which an OpenGL context is active. This means that any interactions with Drawer
, ColorBuffer
, VertexBuffer
, RenderTarget
, Shader
, BufferTexture
, CubeMap
, ArrayTexture
can only be performed on the primary draw thread or specially created draw threads.
Coroutines
Coroutines, as they are discussed here are a Kotlin specific framework for concurrency. Please read the coroutines overview in the Kotlin reference for an introduction to coroutines
Program
comes with its own coroutine dispatcher, which guarantees that coroutines will be handled on the primary draw thread. This means that coroutines when executed or resumed by the program dispatcher will block the draw thread.
In the following example we launch a coroutine that slowly counts to 99. Note that the delay inside the coroutine does not block the primary draw thread.
fun main() = application {
program {
var once = true
extend {
if (once) {
once = false
launch {
for (i in 0 until 100) {
println("Hello from coroutine world ($i)")
delay(100)
}
}
}
}
}
}
You may be asking, what is the purpose of the Program
dispatcher if running coroutines blocks the primary draw thread. The answer is, blocking coroutines are useful when the work performed is light. Light work includes waiting for (off-thread) coroutines to complete and using the results to write to graphics resources.
In the below example we nest coroutines; the outer one is launched on the Program
dispatcher, the inner one is launched on the GlobalScope
dispatcher. The GlobalScope
dispatcher executes the coroutine on a thread (from a thread pool) such that it does not block the primary draw thread. By using .join()
on the inner coroutine we wait for it to complete, waiting is non-blocking (thanks to coroutine magic!). Once the join operation completes we can write the results to a graphics resource on the primary draw thread.
fun main() = application {
program {
val colorBuffer = colorBuffer(512, 512)
val data = ByteBuffer.allocateDirect(512 * 512 * 4)
var once = true
extend {
if (once) {
once = false
launch {
// -- launch on GlobalScope
// -- this will cause the coroutine to be executed off-thread.
GlobalScope.launch {
// -- perform some faux-heavy calculations
val r = Random(100)
for (y in 0 until 512) {
for (x in 0 until 512) {
for (c in 0 until 4) {
data.put(r.nextBytes(1)[0])
}
}
}
}.join() // -- wait for coroutine to complete
// -- write data to graphics resources
data.rewind()
colorBuffer.write(data)
}
}
}
}
}
Secondary draw threads
In some scenarios you may want to have a separate thread on which all graphic resources can be used and drawing is allowed. In those cases you use drawThread
.
Most graphic resources can be used and shared between threads, with the exception of the RenderTarget
.
In the next example we create a secondary draw thread and a ColorBuffer
that is shared between the threads. On the secondary draw thread we create a RenderTarget
with the color buffer attachment. The image is made visible on the primary draw thread.
fun main() = application {
program {
val result = colorBuffer(512, 512)
var once = true
var done = false
val secondary = drawThread()
extend {
if (once) {
once = false
// -- launch on the secondary draw thread (SDT)
secondary.launch {
// -- create a render target on the SDT.
val rt = renderTarget(512, 512) {
colorBuffer(result)
}
// -- make sure we use the draw thread's drawer
val drawer = secondary.drawer
drawer.withTarget(rt) {
drawer.ortho(rt)
drawer.clear(ColorRGBa.PINK)
}
// -- destroy the render target
rt.destroy()
finish()
// -- tell main thread the work is done
done = true
}
}
// -- draw the result when the work is done
if (done) {
drawer.image(result)
}
}
}
}