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:
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:
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:
NatinyResource source = natiny_resource_load("data/mrt_preview.nsl");
NatinyShader shader = natiny_shader_load(source);
natiny_resource_destroy(source);
NatinyMaterial material = natiny_material_create(shader);
Check every returned handle before continuing. The source resource is no
longer needed after natiny_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:
NatinySurface first = natiny_surface_create(
320, 180, NATINY_FORMAT_COLOR, false);
NatinySurface second = natiny_surface_create(
320, 180, NATINY_FORMAT_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:
natiny_surface_bind(first, 0);
natiny_surface_bind(second, 1);
natiny_render_clear(0.0f, 0.0f, 0.0f, 1.0f);
natiny_material_bind(material);
natiny_render_cover();
natiny_material_unbind();
natiny_surface_unbind();
natiny_render_cover covers the complete target.
Every generated fragment runs the shader once: COLOR fills first, while
TARGET1 fills second.
Calling natiny_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:
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:
natiny_render_clear(0.04f, 0.05f, 0.08f, 1.0f);
natiny_render_set_color(1.0f, 1.0f, 1.0f, 1.0f);
natiny_render_surface(first, 20.0f, 20.0f, 320.0f, 180.0f);
natiny_render_surface(second, 360.0f, 20.0f, 320.0f, 180.0f);
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.
natiny_window_bind(window);
/* Pass 1: one draw writes two surfaces. */
natiny_surface_bind(first, 0);
natiny_surface_bind(second, 1);
natiny_render_clear(0.0f, 0.0f, 0.0f, 1.0f);
natiny_material_bind(material);
natiny_render_cover();
natiny_material_unbind();
natiny_surface_unbind();
/* Pass 2: display the completed surfaces in the window. */
natiny_render_clear(0.04f, 0.05f, 0.08f, 1.0f);
natiny_render_set_color(1.0f, 1.0f, 1.0f, 1.0f);
natiny_render_surface(first, 20.0f, 20.0f, 320.0f, 180.0f);
natiny_render_surface(second, 360.0f, 20.0f, 320.0f, 180.0f);
natiny_window_unbind();
Never sample or display an MRT surface before natiny_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:
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:
NatinySurface albedo = natiny_surface_create(
width, height, NATINY_FORMAT_RGBA8, true);
NatinySurface normals = natiny_surface_create(
width, height, NATINY_FORMAT_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 width and height.
Release the MRT resources
When the pass is no longer needed, release its resources while the engine is still running:
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.