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. When the surface is unbound, drawing returns 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:
local scene = natiny.surface.create(320, 180)
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.
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 and draw the off-screen
picture:
natiny.surface.bind(scene, function()
natiny.render.clear(0.08, 0.10, 0.16, 1.0)
natiny.render.set_color(0.96, 0.67, 0.30, 1.0)
natiny.render.circle(160, 90, 24)
end)
The callback form of
surface.bind restores the window target when the
callback finishes. 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.03, 0.04, 0.06, 1.0)
natiny.render.set_color(1, 1, 1, 1)
natiny.render.surface(scene, 0, 0, 960, 540)
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 drawing the surface produces an error or an empty result, check that the
surface.bind callback has already returned before render.surface runs.
Run the complete moving example
Paste this program into the browser sandbox. You should see an orange circle moving across a deliberately low-resolution image stretched to fill the window.
local natiny = require("natiny")
assert(natiny.backend.init())
local window = natiny.window.create("A tiny off-screen world", 960, 540)
local scene = natiny.surface.create(320, 180)
local time = 0
natiny.backend.loop(function(dt)
time = time + dt
natiny.window.bind(window, function()
natiny.surface.bind(scene, function()
natiny.render.clear(0.08, 0.10, 0.16, 1.0)
natiny.render.set_color(0.96, 0.67, 0.30, 1.0)
natiny.render.circle(160 + math.cos(time * 2) * 90, 90, 24)
end)
natiny.render.clear(0.03, 0.04, 0.06, 1.0)
natiny.render.set_color(1, 1, 1, 1)
natiny.render.surface(
scene, 0, 0,
natiny.window.get_width(window),
natiny.window.get_height(window)
)
end)
end)
natiny.surface.destroy(scene)
natiny.backend.shutdown()
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 short form used above creates a window-compatible colour surface with a depth buffer:
local scene = natiny.surface.create(320, 180)
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:
local scene = natiny.surface.create(
320, 180, natiny.surface.COLOR, false)
Choose another format only when the surface stores more than a displayable picture:
| Format | Use it for |
|---|---|
COLOR |
A picture that will be drawn into a window |
RGBA8 |
Four explicit 8-bit colour channels |
RGBA16F |
HDR colour, normals, or positions |
RGBA32F |
Data that needs full floating-point precision |
R32F |
One floating-point value per pixel |
Floating-point formats use more memory and bandwidth. For an ordinary scene,
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:
local w = natiny.window.get_width(window)
local h = natiny.window.get_height(window)
if w > 0 and h > 0 and
(w ~= natiny.surface.get_width(scene) or
h ~= natiny.surface.get_height(scene)) then
natiny.surface.resize(scene, w, h)
end
The positive-size check avoids trying to resize to zero while a window is
minimized. 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
render.surface is enough to display the image.
Post-processing shaders instead need the surface's colour texture:
local texture = natiny.surface.get_texture(scene)
natiny.material.set_texture(material, "scene", texture)
If the surface was created with a depth buffer, its depth texture is available separately:
local 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 = nil
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.