Render to a surface
Drawing normally goes straight to a window. Sometimes you need the finished picture before it reaches the window: a minimap, a security camera, a mirror, or the input for a post-processing effect.
In this guide, we will draw a moving circle into a 320 by 180 off-screen surface, then enlarge that image to fill a 960 by 540 window.
How an off-screen surface changes the drawing path
A render target is the image that receives drawing commands. The window is the usual render target. A surface is an off-screen render target whose colour image can later be drawn like a texture.
Our frame will follow this path:
shapes -> 320x180 surface -> window
Binding the surface temporarily replaces the window as the current target. Unbinding it returns drawing to the window. This order matters: a surface cannot be read as an image while the same surface is still receiving drawing commands.
Create the off-screen image
Create the surface once, after creating the window:
NatinySurface scene = natiny_surface_create(
320, 180, NATINY_FORMAT_COLOR, true);
The dimensions are the resolution of the stored image, not its final size on
screen. We use a low resolution so the difference is easy to see when the
result is enlarged. Check that the returned handle is not 0 before using it.
Draw into the surface, then show it
The window must be bound first because the surface records its work inside the
window's active frame. Inside that frame, bind scene to target slot 0 and
draw the off-screen picture:
natiny_surface_bind(scene, 0);
natiny_render_clear(0.08f, 0.10f, 0.16f, 1.0f);
natiny_render_set_color(0.96f, 0.67f, 0.30f, 1.0f);
natiny_render_circle(160.0f, 90.0f, 24.0f);
natiny_surface_unbind();
natiny_surface_unbind finishes the off-screen
pass and restores the window target. At this point, scene contains a dark
background and an orange circle.
Now clear the window and draw the completed surface across it:
natiny_render_clear(0.03f, 0.04f, 0.06f, 1.0f);
natiny_render_set_color(1.0f, 1.0f, 1.0f, 1.0f);
natiny_render_surface(scene, 0.0f, 0.0f, 960.0f, 540.0f);
Keep the draw colour white. Like other 2D images, a surface is multiplied by the current draw colour; another colour would tint the result.
The target state has now changed twice:
surface bound: shapes -> scene
surface released: scene -> window
If the window is black, check that natiny_window_bind surrounds both parts.
If the surface appears empty, check that natiny_surface_unbind runs before
natiny_render_surface.
Run the complete moving example
This program draws the surface every frame and then stretches it to the current window size. You should see an orange circle moving across a deliberately low-resolution image.
#include "natiny.h"
#include <math.h>
#include <stddef.h>
static NatinyWindow window;
static NatinySurface scene;
static float elapsed;
static void frame(float dt, void* userdata)
{
float x;
(void)userdata;
elapsed += dt;
x = 160.0f + cosf(elapsed * 2.0f) * 90.0f;
natiny_window_bind(window);
natiny_surface_bind(scene, 0);
natiny_render_clear(0.08f, 0.10f, 0.16f, 1.0f);
natiny_render_set_color(0.96f, 0.67f, 0.30f, 1.0f);
natiny_render_circle(x, 90.0f, 24.0f);
natiny_surface_unbind();
natiny_render_clear(0.03f, 0.04f, 0.06f, 1.0f);
natiny_render_set_color(1.0f, 1.0f, 1.0f, 1.0f);
natiny_render_surface(
scene, 0.0f, 0.0f,
(float)natiny_window_get_width(window),
(float)natiny_window_get_height(window));
natiny_window_unbind();
}
int main(void)
{
if (!natiny_backend_init(NATINY_BACKEND_AUTO)) {
return 1;
}
window = natiny_window_create("A tiny off-screen world", 960, 540);
scene = natiny_surface_create(
320, 180, NATINY_FORMAT_COLOR, true);
if (!window || !scene) {
natiny_backend_shutdown();
return 1;
}
natiny_backend_loop(frame, NULL);
natiny_surface_destroy(scene);
natiny_backend_shutdown();
return 0;
}
The surface is redrawn every frame before it is displayed, so the window always receives the latest position of the circle.
Choose the smallest suitable format
The example creates a window-compatible colour surface with a depth buffer:
NatinySurface scene = natiny_surface_create(
320, 180, NATINY_FORMAT_COLOR, true);
A depth buffer decides which of overlapping 3D fragments is in front. The 2D example does not need one, so it can save that allocation:
NatinySurface scene = natiny_surface_create(
320, 180, NATINY_FORMAT_COLOR, false);
Choose another format only when the surface stores more than a displayable picture:
| Format | Use it for |
|---|---|
NATINY_FORMAT_COLOR |
A picture that will be drawn into a window |
NATINY_FORMAT_RGBA8 |
Four explicit 8-bit colour channels |
NATINY_FORMAT_RGBA16F |
HDR colour, normals, or positions |
NATINY_FORMAT_RGBA32F |
Data that needs full floating-point precision |
NATINY_FORMAT_R32F |
One floating-point value per pixel |
Floating-point formats use more memory and bandwidth. For an ordinary scene,
NATINY_FORMAT_COLOR is the useful default.
Resize a full-resolution surface with the window
Our 320 by 180 surface intentionally stays small. A full-resolution post-processing effect usually needs to follow the window instead:
uint32_t w = (uint32_t)natiny_window_get_width(window);
uint32_t h = (uint32_t)natiny_window_get_height(window);
if (w > 0 && h > 0 &&
(w != natiny_surface_get_width(scene) ||
h != natiny_surface_get_height(scene))) {
natiny_surface_resize(scene, w, h);
}
The positive-size check avoids trying to resize to zero while a window is
minimized. natiny_surface_resize discards the
old pixels, so redraw the surface after resizing. The texture handles are not
affected: a resize replaces what is behind them, not the handles themselves.
Pass the result to a custom material
natiny_render_surface is enough to display the
image. Post-processing shaders instead need the surface's colour texture:
NatinyTexture texture = natiny_surface_get_texture(scene);
natiny_material_set_texture(
material, "scene", texture,
NATINY_FILTER_LINEAR, NATINY_FILTER_LINEAR);
If the surface was created with a depth buffer, its depth texture is available separately:
NatinyTexture depth = natiny_surface_get_depth(scene);
Both texture handles belong to the surface. Do not destroy them. They stay valid for the life of the surface, a resize included - a material given one of them once never has to be told again.
Release the surface
Destroy the surface while the engine is still running, once no draw or material uses it:
natiny_surface_destroy(scene);
scene = 0;
The surface releases the colour texture and its optional depth texture with it.
You now have the complete render-to-texture model: bind a window, redirect drawing into a surface, finish that pass, then read the surface from a later pass. The same sequence powers minimaps, mirrors, portals, and post-processing. When one pass must produce several images at once, continue with multiple render targets.