Multiple render targets

A fragment shader usually produces one colour image. Some rendering passes need several descriptions of the same pixel: its colour, normal, material ID, or motion. Drawing the geometry again for every image repeats work.

Multiple render targets (MRT) let one draw write those values to several surfaces at once. In this guide, one full-screen draw will produce two different gradients, which we will display side by side.

This guide builds on rendering to a surface. Read that first if binding and displaying a surface are not familiar yet.

How one fragment reaches several surfaces

Each bound surface occupies a numbered target slot. The fragment shader has a matching output for each slot:

TEXT
one fragment shader invocation
        |
        +-- COLOR   -> slot 0 -> first surface
        +-- TARGET1 -> slot 1 -> second surface

Both values describe the same fragment and are produced by the same draw. MRT does not feed one surface into the next; that would require separate passes.

NSL exposes four outputs:

Shader output Target slot
COLOR, or TARGET0 0
TARGET1 1
TARGET2 2
TARGET3 3

COLOR and TARGET0 are two names for the one output, not two outputs. Write whichever reads better: COLOR in a shader that produces a single image, and TARGET0 in a pass like the one below, where numbering every output the same way makes the slot each one fills obvious.

Create a shader with two outputs

Create data/mrt_preview.nsl:

NSL
attribute vec3 position;
attribute vec2 texcoord0;

varying vec2 uv;

uniform mat4 mtx_worldviewproj;

void vertex()
{
    uv = texcoord0;
    POSITION = mtx_worldviewproj * vec4(position, 1.0);
}

void fragment()
{
    COLOR   = vec4(uv.x, 0.2, uv.y, 1.0);
    TARGET1 = vec4(uv.y, uv.x, 0.1, 1.0);
}

COLOR and TARGET1 deliberately swap the gradient channels. When both images appear different, we will know that each output reached its intended surface rather than seeing the same surface twice.

Load the shader and create the material that will run it:

Lua
local source = natiny.resource.load("data/mrt_preview.nsl")
local shader = natiny.shader.load(source)
natiny.resource.destroy(source)
local material = natiny.material.create(shader)

The source resource is no longer needed after shader.load, but the shader and material must stay alive for every frame that uses them.

Create two matching surfaces

An MRT pass shares one viewport across all its outputs, so every attached surface must have exactly the same dimensions:

Lua
local first = natiny.surface.create(
    320, 180, natiny.surface.COLOR, false)
local second = natiny.surface.create(
    320, 180, natiny.surface.COLOR, false)

These gradients are 2D colour images, so neither surface needs a depth buffer. For a 3D pass, put the depth buffer on slot 0. Slot 0 controls the viewport, 2D projection, and depth attachment for the whole pass.

If the dimensions differ, binding the later surface is refused and the engine prints a message. Create and resize MRT surfaces as a group.

Bind both targets before drawing

Inside a bound window, attach both surfaces before the first draw:

Lua
natiny.surface.bind(first, 0)
natiny.surface.bind(second, 1)

natiny.render.clear(0, 0, 0, 1)
natiny.material.bind(material, natiny.render.cover)

natiny.surface.unbind()

render.cover covers the complete target. Every generated fragment runs the shader once: COLOR fills first, while TARGET1 fills second.

Use explicit binds for an MRT pass. Calling surface.unbind() once releases all target slots and returns drawing to the window; there is no per-slot unbind.

The state after this step is:

TEXT
first  = COLOR gradient
second = TARGET1 gradient
current target = window

Display both results

Now that the MRT pass is finished, draw the surfaces like ordinary images:

Lua
natiny.render.clear(0.04, 0.05, 0.08, 1)
natiny.render.set_color(1, 1, 1, 1)
natiny.render.surface(first, 20, 20, 320, 180)
natiny.render.surface(second, 360, 20, 320, 180)

You should see two differently coloured gradients next to each other. If the second rectangle is black, check that second was bound to slot 1 before the draw and that the shader writes TARGET1. If both rectangles are wrong or empty, check that the window was bound and that the MRT pass was unbound before the surfaces were displayed.

Put the complete frame together

The entire frame now has two distinct passes: first produce both textures, then read them while drawing the window.

Lua
natiny.window.bind(window, function()
    -- Pass 1: one draw writes two surfaces.
    natiny.surface.bind(first, 0)
    natiny.surface.bind(second, 1)
    natiny.render.clear(0, 0, 0, 1)
    natiny.material.bind(material, natiny.render.cover)
    natiny.surface.unbind()

    -- Pass 2: display the completed surfaces in the window.
    natiny.render.clear(0.04, 0.05, 0.08, 1)
    natiny.render.set_color(1, 1, 1, 1)
    natiny.render.surface(first, 20, 20, 320, 180)
    natiny.render.surface(second, 360, 20, 320, 180)
end)

Never sample or display an MRT surface before surface.unbind(). At that point the GPU is still using it as an output, not as an input for a later pass.

Store colour and normals for later lighting

A deferred renderer first records scene properties, then reads them in a lighting pass. The first working example used gradients; this fragment shader applies the same mechanism to colour and a world-space normal:

NSL
attribute vec3 position;
attribute vec4 color0;
attribute vec3 normal;

varying vec4 vertex_color;
varying vec3 world_normal;

uniform mat4 mtx_worldviewproj;
uniform mat4 mtx_world;

void vertex()
{
    vertex_color = color0;
    world_normal = mat3(mtx_world) * normal;
    POSITION = mtx_worldviewproj * vec4(position, 1.0);
}

void fragment()
{
    vec3 n = normalize(world_normal);
    TARGET0 = vertex_color;
    TARGET1 = vec4(n * 0.5 + 0.5, 1.0);
}

TARGET0 here is the same output the first shader called COLOR. A pass that fills a G-buffer usually writes it this way: the outputs then read as a numbered set, and each one's slot is the number in its name.

Normal components range from -1 to 1. Multiplying by 0.5 and adding 0.5 maps that range to 0 through 1, so the normal can be inspected as a colour image. For later lighting, use a floating-point surface to preserve more precision:

Lua
local albedo = natiny.surface.create(
    w, h, natiny.surface.RGBA8, true)
local normals = natiny.surface.create(
    w, h, natiny.surface.RGBA16F, false)

Only albedo needs depth because it occupies slot 0 and supplies depth for the entire pass. Both surfaces must still use the same w and h.

Release the MRT resources

When the pass is no longer needed, release its resources while the engine is still running:

Lua
natiny.surface.destroy(first)
natiny.surface.destroy(second)
natiny.material.destroy(material)
natiny.shader.destroy(shader)

Destroy the material before its shader because the material refers to that shader. Do not use either surface after destroying it.

MRT is a good fit when every output describes the same rendered fragments. It saves repeated geometry work, but each additional surface still costs texture memory and write bandwidth. When an effect needs the completed output of an earlier effect, finish the first pass and start another instead.

The model to keep is: bind equal-sized surfaces to numbered slots, write the matching shader outputs, draw once, then unbind before reading any result. That pattern scales from the two visible gradients here to deferred-rendering buffers containing colour, normals, positions, and material data.