Render targets - wildware-uk/composegl GitHub Wiki

Render targets

A render target is a picture the toolkit draws into instead of the window: a terminal on a wall, the screen on a gun, or a 3D scene shown inside a panel. The same tree and the same canvas draw into it. Only where the pixels land is different.

What comes out is premultiplied. Draw it on a quad with ONE, ONE_MINUS_SRC_ALPHA for a screen, or ONE, ONE for a hologram. No shader of your own is needed.

Light drawn into it adds colour and no opacity, so that same quad adds a glow onto the scene behind exactly as the glow looks drawn straight, even past the panel's paint, where the pixel's alpha stays nought:

target.draw(canvas) {
    canvas.rect(panel, face)                       // paint: covers the scene behind it
    canvas.pushBlend(BlendMode.Additive)
    canvas.rect(halo, Colour(0x80FFE080.toInt()))  // light: adds onto the scene, even past the panel
    canvas.popBlend()
}

A quad drawn with straight alpha (SRC_ALPHA, ONE_MINUS_SRC_ALPHA) drops that light, and puts a dark rim round every soft edge besides. A KorgeRenderTarget is the exception: KorGE's sprite batch throws away any pixel with no opacity, so there light keeps its opacity and covers what is behind it by that much (see KorGE).

frontend class made with
raw OpenGL (LWJGL3) GlRenderTarget GlRenderTarget(512, 256)
LibGDX GdxRenderTarget GdxRenderTarget(512, 256)
a browser tab (WebGL) WebGlRenderTarget WebGlRenderTarget(gl, 512, 256)
KorGE KorgeRenderTarget see KorGE
your own backend RenderTarget RenderTarget(device, 512, 256)
val target = GlRenderTarget(512, 256)

// In the game loop: redraw only when the panel changed.
if (panel.needsRedraw(now)) panel.draw(canvas) { tree -> target.draw(canvas) { tree() } }
scene.drawQuad(target.texture)

The panel opens the target's frame itself, through the block. That way it can render any Scene view inside it first, before the frame opens. A panel with no scene view in it can also be drawn the older way, target.draw(canvas) { panel.draw(canvas) }.

To show a 3D scene inside a panel of your interface, you usually want Scene view instead: it makes the target, sizes it to the panel, and redraws it only when asked.

draw clears the picture, runs your block in a frame of the canvas, and puts back the framebuffer and viewport it found. So you can call it in the middle of your own scene.

resize(width, height) gives the old picture back to the GPU at once, so a panel that follows the window's size does not leak one per frame. close() (or dispose() on LibGDX) gives it all back.


A framebuffer of your own around the whole interface

A whole-screen effect, such as a colour grade, binds a framebuffer of its own around the frame, interface included. A canvas that leaves GL state as it is documented (HostState.Leave: LWJGL3, LibGDX, and WebGL unless you chose Restore) asks the driver which framebuffer is yours only once, on the first frame, and then remembers it, because asking every frame makes the CPU wait for the driver. So when the framebuffer around the interface changes, say so with hostTargetChanged():

fun grade(on: Boolean) {
    if (on != grading) canvas.hostTargetChanged()   // the next frame asks again
    grading = on
}

// Every frame:
if (grading) grade.begin()          // binds the grade's own framebuffer
ui.render(viewport, System.nanoTime())
if (grading) grade.end()            // draws it to the window

Call it when the effect is switched on, when it is switched off, and when its framebuffer is made again at a new size. The frame after each call asks the driver once; the frames after that ask nothing. Without it the interface keeps drawing into the framebuffer it remembers. A frame you open yourself can name the framebuffer instead: canvas.begin(viewport, framebuffer) on GlCanvas, GdxCanvas and WebGlCanvas.


Depth: drawing a 3D scene into one

Out of the box a render target has colour and nothing else. That is all the interface needs. But a 3D scene drawn into it comes out inside-out: the back of a model paints over its front, because there is nothing to test depth against.

Ask for a depth buffer with depth = true:

val preview = GlRenderTarget(480, 270, depth = true)

preview.draw(canvas, clear = Colour.Black) {
    canvas.raw { frame ->
        GL11.glEnable(GL11.GL_DEPTH_TEST)
        myRenderer.draw(frame as GlFrame, preview.width, preview.height)
        GL11.glDisable(GL11.GL_DEPTH_TEST)
    }
}

GdxRenderTarget(480, 270, depth = true) and WebGlRenderTarget(gl, 480, 270, depth = true) work the same way.

What you get:

  • A depth buffer the picture's own size. It is made with the picture, remade when the picture is resized, and given back when it is closed.
  • It is cleared with the colour. Every draw clears depth to the far plane at the same moment it clears the colour, so each frame starts from nothing in both.
  • The depth test is yours. The toolkit's own drawing never uses depth, so it leaves the test off. Switch it on inside raw { } for your scene, as above.

Clearing depth only works while depth writing is on, so the toolkit switches it on first. With HostState.Leave, the default on LWJGL3, LibGDX and WebGL, it is left on afterwards, which is OpenGL's own default. With HostState.Restore it goes back to what your engine had.

On KorGE, KorgeRenderTarget already has a depth and stencil buffer, because every KorGE framebuffer does, and its draw clears them with the colour.


Very big sizes

A GPU has a biggest texture it will make, often 4096 or 8192 pixels a side. A panel on a large screen can ask for more than that. Rather than fail in the middle of a frame, GlRenderTarget, GdxRenderTarget, WebGlRenderTarget and RenderTarget cut the size down to the biggest the GPU allows, and say so:

val target = GlRenderTarget(20_000, 300)
if (target.clamped) println("drawing at ${target.width}x${target.height} instead")

width and height are always the size you really got. Asking again for a size that cuts down to the same one costs nothing.


In your own backend

The frontends above are thin wrappers over RenderTarget in composegl-render, which works against any GpuDevice:

val target = RenderTarget(device, 480, 270, depth = true)
target.draw(canvas, Colour.Black) { /* ... */ }
val pixels = target.read()   // premultiplied RGBA, bottom row first
target.close()

Underneath, it calls GpuDevice.offscreen(width, height, depth). On OpenGL that is GlDevice, which adds a renderbuffer as the depth attachment. A Gl binding needs the five renderbuffer calls (createRenderbuffer, bindRenderbuffer, deleteRenderbuffer, renderbufferStorage, framebufferRenderbuffer) plus depthMask and clearDepth, each a one-liner onto your GL. See Backends.

A framebuffer your engine made itself can be drawn into with GlDeviceTarget.adopt(framebuffer, texture, width, height, depth = true). Say depth = true only if it really has a depth attachment. The toolkit then clears it with the colour, and never deletes it.