The bind stack

A typical frame draws to several places: a shadow map, a reflection, a G-buffer, a bloom chain, and finally the window. Some targets are nested. Others are bound one after another.

That is where the questions start. If I bind a surface in the middle of a scene, what happens to the scene? When I unbind, do I get everything back, or only some of it? Does unbinding a camera go back to the previous camera? Why did my shadow pass come out flat, or in the wrong aspect ratio, or with only half the pixels updated?

This guide gives you one model for answering these questions. With that model, you can design a render pipeline without having to discover the rules through debugging. The guide assumes that you have already drawn into a surface. If not, start with render to a surface.

Only one thing nests

The complete mental model is simple:

Targets nest. Nothing else does.

A target is a surface or a window: the image that receives drawing commands. Binding a surface to slot 0 suspends the current target set and starts a new one. Unbinding it removes that set and restores the suspended state.

TEXT
window                     <- bottom of the stack
  scene surface            <- bind
    minimap surface        <- bind
    (unbind)               -> back to the scene surface
  (unbind)                 -> back to the window

Cameras, materials, and colours do not nest. They are values that affect the next draw, much like the current colour of a pen. Binding a new value replaces the previous one. This may seem to lose the scene camera when a shadow pass binds another camera. It does not, because the target stack saves the scene camera. The following sections explain how this works.

A bind starts a new render pass

One fact about the hardware explains the rest of the rules.

Render passes do not nest on a GPU. A GPU cannot pause a pass, run another pass inside it, and then resume the first one. If you bind a minimap halfway through a scene, Natiny divides the work into three passes:

TEXT
pass 1   scene, first half
pass 2   minimap
pass 3   scene, second half   (loads pass 1's pixels back)

This has two important consequences.

First, a bind flushes the batch. Before Natiny binds the new target, it draws everything already queued into the current target. This work includes a draw call and a pass boundary, so bind a target around a group of draws, not around a single draw.

Second, the third pass usually needs the drawing state from the first pass. Natiny therefore restores the state that belongs to the suspended target. An explicit viewport is the exception. Changing targets ends it because its rectangle belongs to the old target.

What the stack carries and restores

The two directions of a bind are not symmetric. The table shows which state enters a surface and which state returns when you leave it.

State Carried into a surface bind Restored at the unbind
Draw colour (natiny_render_set_color) yes yes
Clear colour yes yes
Bound material yes yes
Bound camera no; starts empty yes
Scissor rects no; start empty yes
Active viewport no; the target change ends it no
Coordinate scale no; 1 unit = 1 pixel yes
Current texture no yes

Except for the viewport, Natiny restores the suspended state. State bound inside a surface does not affect its parent target after the surface is unbound.

The left column has one important division. Camera, scissor, viewport, and coordinate scale do not carry into the new target because each depends on the target's pixels. A camera projection uses the target's aspect ratio. A scissor rectangle belongs to a particular image. A coordinate scale for a 1920 by 1080 window has no useful meaning inside a 512 by 512 shadow map.

Carrying these values into the new target would make them incorrect. Natiny resets them when the bind begins. On unbind, it restores the camera, scissor, and coordinate scale. The explicit viewport remains ended.

The values marked "yes" do not depend on geometry: draw colour, clear colour, and material. Orange remains orange in every target. These values enter the surface, and Natiny restores them when the surface is unbound.

Note

Natiny restores a camera as matrices, not as a handle. If the camera node moves between the bind and the unbind, the restored view is still the one used by the suspended pass. It does not change to the camera node's new position.

Binding state and object state

Everything above is about binding state: what is currently bound, what colour is current, which rectangle is current. That is the state the stack saves and restores.

The stack does not change the second kind: object state. Object state describes the object itself, rather than which object is currently selected.

C
natiny_surface_bind(shadow_map, 0);
natiny_material_bind(shadow_mat);                          /* binding state: restored  */
natiny_material_set_constant(shadow_mat, "bias",
                             0.002f, 0.0f, 0.0f, 0.0f);    /* object state: permanent  */
natiny_surface_unbind();

After the unbind, shadow_mat is no longer bound because Natiny restored the previous binding. However, its bias constant remains 0.002 in every later pass that uses this material. The call changed the material itself.

The same applies to natiny_camera_set_fov, natiny_camera_set_clip, node transforms, and the pixels inside a surface. None of these changes is a binding, so the stack does not restore them. If you need two configurations in one frame, create two materials instead of reconfiguring one material between passes.

A useful rule: bind is restored, set_* on a handle is not.

Cameras and materials do not go "one level back"

This rule is important: an unbind does not reveal an earlier camera or material binding.

C
natiny_camera_bind(scene_cam);
natiny_camera_bind(light_cam);
natiny_camera_unbind();          /* does NOT bring back scene_cam */

natiny_camera_unbind() means no camera. Drawing falls back to the target's 2D projection, and it is the same call as natiny_camera_bind(0). It does not mean "one level back", because there are no levels. The same is true of natiny_material_unbind(), which means no material override.

You do not need to bind the scene camera again after a shadow pass. The target stack restores it:

C
natiny_camera_bind(scene_cam);

natiny_surface_bind(shadow_map, 0);
    natiny_render_clear(1.0f, 1.0f, 1.0f, 1.0f);
    natiny_camera_bind(light_cam);
    natiny_render_tag("shadow", NATINY_SORT_MATERIAL);
natiny_surface_unbind();          /* scene_cam is back, matrices and all */

natiny_render_tag("scene", NATINY_SORT_MATERIAL);       /* drawn with scene_cam */

The shadow pass does not unbind its camera. When the surface is unbound, Natiny discards that camera binding and restores the binding for the parent target.

If you need a temporary camera without changing targets, bind the camera you want next explicitly:

C
natiny_camera_bind(portal_cam);
draw_world();
natiny_camera_bind(scene_cam);    /* not unbind: name what you want */

The explicit bind makes the next camera clear in the code. There is no hidden camera stack to follow.

Windows form the base of the stack

A window is also a target, but it cannot be bound in the same way as a surface.

A frame belongs to a window. Its coordinate system, acquired swapchain image, and command encoder all come from the window bound at the top. Binding a window therefore drops any surfaces still bound and reports their number:

TEXT
[natiny] window_unbind: 2 surface bind(s) were still open and have been dropped

Balanced code reaches the end of a frame with no surfaces still bound. This message reports a missing unbind while its source is still easy to find.

Use this structure:

TEXT
natiny_window_bind
  natiny_surface_bind ... natiny_surface_unbind
  natiny_surface_bind ... natiny_surface_unbind
natiny_window_unbind

Do not place a natiny_window_bind inside a surface bind. Two windows mean two frames, regardless of how the calls are nested. An engine frame holds one acquired image per window. Returning to a window after another window was bound starts a new image instead of continuing the old one.

Note

Under NATINY_SCALE_MODE_STRETCH the window you bind is really an off-screen canvas at the window's logical size, copied onto the window once per frame, just before the commands are submitted. Binding and unbinding the window several times in one frame therefore costs one copy, not one copy per bind. This requires no action in application code. It only explains the extra target you may see when inspecting the stack.

Nothing clears itself

A surface keeps its pixels between binds and between frames. A frame boundary does not clear it.

For colour, this is often harmless because the next pass draws over it. For depth, it can cause a confusing bug:

C
natiny_surface_bind(reflection, 0);
    /* no clear */
    natiny_camera_bind(reflect_cam);
    natiny_render_tag("world", NATINY_SORT_MATERIAL);
natiny_surface_unbind();

The depth buffer still contains values from the previous frame. New fragments are tested against those values, so only fragments that are nearer can pass. The image then updates in patches and may look like a corrupted texture.

Therefore, clear any pass that redraws its complete image. This includes a scene from a moving camera, a reflection, a G-buffer, and a shadow map.

C
natiny_render_clear(0.0f, 0.0f, 0.0f, 1.0f);

A pass that replaces every pixel with an opaque full-screen draw, such as natiny_render_cover with blending disabled, does not need a clear. Skipping the clear in this case avoids unnecessary work.

The clear colour enters a surface bind, as shown in the table. If an earlier pass set it to transparent black, the new pass inherits that value. Pass a colour explicitly to natiny_render_clear to make the result clear from the code.

Designing a render pipeline

The following example renders 32 shadow-casting lights with six depth maps each, followed by the scene and post-processing.

C
natiny_window_bind(win);
/* 192 shadow maps, one after another */
for (uint32_t light = 0; light < 32; light++) {
    for (uint32_t face = 0; face < 6; face++) {
        natiny_surface_bind(shadow[light][face], 0);
        natiny_render_clear(1.0f, 1.0f, 1.0f, 1.0f);
        natiny_camera_bind(light_cam[light][face]);
        natiny_render_tag("shadow", NATINY_SORT_MATERIAL);
        natiny_surface_unbind();
    }
}

/* draw the scene into HDR; a new target starts with no camera */
natiny_surface_bind(hdr, 0);
natiny_render_clear(0.0f, 0.0f, 0.0f, 1.0f);
natiny_camera_bind(scene_cam);
natiny_render_tag("world", NATINY_SORT_MATERIAL);
natiny_surface_unbind();

/* tone-map onto the window */
natiny_material_bind(tonemap_mat);
natiny_render_cover();
natiny_material_unbind();

natiny_window_unbind();

The example shows two useful properties of the stack.

The stack never becomes deeper than one surface. The frame performs 192 binds, but each surface is unbound before the next one is bound. Nesting depth is different from the total number of binds. Natiny allows 64 nested levels. For comparison, a shadow atlas inside a reflection inside a frame uses three levels.

Saving target state is inexpensive. Natiny uses a fixed-size memory copy, with no allocation or GPU command. The significant cost comes from ending one render pass and starting another. When designing the pipeline, focus on the number of passes rather than the number of bind calls.

Typical problems

Everything ends up on the window, or the picture is a frame behind. A surface was not unbound. Check stderr for the window_unbind: N surface bind(s) were still open message. It tells you how many binds remained open.

Models inside a surface come out flat, or in the wrong place. A target bind starts with no camera. Bind a camera inside the surface.

The picture inside a surface has the wrong aspect ratio. The camera was bound before the target. Natiny builds the projection for the target that is current when the camera is bound. Bind the target first, then the camera.

Only some pixels update, and parts of the image are missing. The surface was not cleared, so the depth test uses values from an earlier frame.

A material behaves differently in a later pass than you configured it. Another pass changed the material's object state with natiny_material_set_constant or natiny_material_set_texture. The stack does not restore these changes. Use separate materials for separate configurations.

A camera you unbound did not go back to the previous one. natiny_camera_unbind() always means no camera. Bind the camera you want, or let unbinding a target restore it.

Four rules to remember

The complete model can be reduced to four rules:

  1. Pair each surface bind with an unbind. Use one bind and one unbind. Natiny restores the target state, except for an active viewport, which ends when the target changes.
  2. Everything else is just a value. Camera, material, colour: set them where you draw. Leaving the surface puts them back.
  3. Bind the camera after the target, never before. The projection is built from the current target's aspect ratio.
  4. Nothing clears itself. Call natiny_render_clear in every pass that redraws its complete image. Remember that old depth values can affect a new frame.

Summary

The model has one central idea: targets nest and restore their suspended drawing state. Cameras and materials are values stored with that state. The stack restores what is bound, but it does not undo changes to the objects themselves. Each bind creates a boundary between render passes, so pipeline cost depends mainly on the number of passes rather than the number of binds.

This model is enough to build a complete pipeline with a shadow atlas, a G-buffer, a bloom chain, or a reflection rendered during the scene. When one pass needs to write to several images at once, continue with multiple render targets, which fits into this model as one bind that happens to fill several slots.