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:

TEXT
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:

Lua
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:

Lua
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:

Lua
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:

TEXT
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.

Lua
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:

Lua
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:

Lua
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:

Lua
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:

Lua
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:

Lua
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:

Lua
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.