Off-Screen Rendering in LÖVE 11.x: Canvas Setup, Post-Processing, and Minimaps
Practical guide to off-screen rendering in LÖVE 11.x: create canvases, redirect draw calls, apply post-processing shaders, build a throttled minimap, and read back pixels without stalling the GPU. Includes verification steps and mobile caveats.
04 Jul 2026, 07:54 UTC

Problem and Takeaway
LÖVE games often need to render scenes to an intermediate texture instead of directly to the window—whether for post-processing shaders (bloom, CRT, color grading), a minimap that updates each frame, or capturing screenshots without stalling the main thread. The love.graphics.newCanvas API provides this render-to-texture capability, but misusing it causes VRAM leaks, GPU pipeline stalls, or silent feature loss on mobile GPUs. This guide walks through creating a canvas, redirecting draw calls, applying shaders, generating a minimap, and reading back pixels safely, with verification steps you can run in your own project.
Prerequisites
- LÖVE 11.3 or later (11.4+ adds
Canvas:getTextureType()andCanvas:isReadable()for capability checks). - A project that already runs
love.load,love.update,love.draw. - Basic familiarity with GLSL shaders if you plan post-processing; otherwise the minimap example works with stock drawing functions.
Create the Canvas
Allocate the off-screen target once—typically in love.load—so you don't thrash VRAM each frame.
-- love.load
local canvasWidth, canvasHeight = love.graphics.getDimensions()
local canvas = love.graphics.newCanvas(canvasWidth, canvasHeight, {
type = '2d', -- standard 2D render target
msaa = 0, -- 0 = no MSAA; >0 enables multisampling but disables readable
readable = true -- allows canvas:newImageData() for CPU readback
})
-- Optional: verify the canvas is usable on this hardware
if not canvas:isReadable() then
print('Warning: readable canvas not supported; screenshot/analysis will fail')
end
Where to run: Inside love.load (main thread). Permissions: None beyond normal LÖVE file access. Placeholders: Replace canvasWidth/canvasHeight with your desired off-screen resolution (e.g., 256×256 for a minimap). Risk: Each 1920×1080 RGBA8 canvas consumes ~8 MB VRAM; creating many without releasing leaks memory across level transitions.
Redirect Rendering to the Canvas
Wrap the draw calls you want off-screen between setCanvas(canvas) and setCanvas() (no argument restores the window target).
-- love.draw
love.graphics.setCanvas(canvas)
love.graphics.clear(0, 0, 0, 1) -- clear canvas to opaque black
-- Draw your world / scene here
love.graphics.setColor(1, 0, 0, 1)
love.graphics.rectangle('fill', 100, 100, 200, 150)
-- ... more draw calls ...
love.graphics.setCanvas() -- back to window
-- Now draw the canvas to screen (scaled, rotated, etc.)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(canvas, 0, 0)
Key detail: Canvas coordinates follow the current transform. If your world uses a camera transform (translate/scale), call love.graphics.origin() before drawing to the canvas, or the camera will affect the off-screen render.
Apply a Post-Processing Shader
While the canvas is active, set a shader to modify the rendered pixels. The shader runs per-fragment on the canvas texture.
-- love.load
local bloomShader = love.graphics.newShader[[
extern number time;
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
vec4 c = Texel(tex, tc);
// simple pulse for demo
return c * (1.0 + 0.2 * sin(time * 3.0));
}
]]
-- love.draw (inside canvas block)
love.graphics.setCanvas(canvas)
love.graphics.clear(0, 0, 0, 1)
love.graphics.setShader(bloomShader)
bloomShader:send('time', love.timer.getTime())
-- draw scene
love.graphics.setShader() -- reset shader
love.graphics.setCanvas()
Uniform updates: Send time-varying uniforms each frame inside the canvas block. Batching note: Each setShader/setCanvas switch breaks sprite batching; group all off-screen draws together to minimize state changes.
Generate a Minimap
Render the world to a small canvas (e.g., 256×256) at a reduced tick rate, then draw it scaled in a UI corner.
-- love.load
local minimapCanvas = love.graphics.newCanvas(256, 256, { readable = false })
local minimapScale = 4 -- draw 256x256 as 1024x1024 in corner
local minimapTimer = 0
local MINIMAP_INTERVAL = 0.1 -- update 10×/sec
-- love.update(dt)
minimapTimer = minimapTimer + dt
if minimapTimer >= MINIMAP_INTERVAL then
minimapTimer = minimapTimer - MINIMAP_INTERVAL
love.graphics.setCanvas(minimapCanvas)
love.graphics.clear(0.1, 0.1, 0.1, 1)
love.graphics.origin() -- reset transform so world draws at 0,0
-- draw simplified world (no UI, no particles)
drawWorldForMinimap()
love.graphics.setCanvas()
end
-- love.draw (after main scene)
love.graphics.setColor(1, 1, 1, 0.9)
love.graphics.draw(minimapCanvas,
love.graphics.getWidth() - 256 * minimapScale - 10,
10,
0,
minimapScale,
minimapScale)
Performance: Updating the minimap every frame at full resolution is wasteful. Throttle with a timer and draw a simplified representation (no shaders, no particles).
Read Back Pixels (Screenshots / Analysis)
canvas:newImageData() copies GPU memory to CPU—this stalls the pipeline. Call it after love.draw or inside a love.thread.
-- love.keypressed (example: F12 saves screenshot)
if key == 'f12' then
local imageData = canvas:newImageData() -- blocks GPU
local filename = 'screenshot-' .. os.time() .. '.png'
imageData:encode('png', filename)
print('Saved ' .. filename)
end
Risk: On integrated graphics, a 1080p readback can add 5–15 ms frame time. Throttle to once per few seconds or move to a thread:
-- love.thread (screenshotThread.lua)
local channel = love.thread.getChannel('screenshot')
while true do
local canvas = channel:demand() -- receives Canvas userdata
local data = canvas:newImageData()
local filename = 'shot-' .. os.time() .. '.png'
data:encode('png', filename)
channel:push(filename)
end
-- main thread
local thread = love.thread.newThread('screenshotThread.lua')
thread:start()
function love.keypressed(key)
if key == 'f12' then
love.thread.getChannel('screenshot'):push(canvas)
end
end
Stencil Masking (Optional)
To clip drawing inside the canvas to a shape, enable stencil on setCanvas and use love.graphics.stencil.
love.graphics.setCanvas({canvas, stencil = true})
love.graphics.clear(0,0,0,1)
love.graphics.stencil(function()
love.graphics.circle('fill', 400, 300, 200)
end, 'replace', 1)
love.graphics.setStencilTest('greater', 0)
-- draw scene; only inside circle appears
love.graphics.setStencilTest()
love.graphics.setCanvas()
Caution: Forgetting {stencil = true} silently disables stencil tests—no error is raised.
Release Resources
Call canvas:release() when the canvas is no longer needed (e.g., level unload) to free VRAM immediately.
function unloadLevel()
if canvas then canvas:release(); canvas = nil end
if minimapCanvas then minimapCanvas:release(); minimapCanvas = nil end
end
Verification Checklist
- Canvas count & VRAM: Run
love.graphics.getStats()before and after creation;canvasesshould increment andtexturememoryshould rise by ~width×height×4 bytes. - Pixel sanity: Draw a known pattern (red rectangle at 0,0) to canvas, then
canvas:newImageData():getPixel(0,0)must return255,0,0,255. - Frame overhead: Toggle canvas rendering on/off while watching
love.timer.getAverageDelta(); expect <1 ms for a single 1080p canvas on desktop. - Mobile readable test: On target device, create a readable canvas, call
newImageData(), confirm no error and frame time stays in budget. - Capability fallback: Check
love.graphics.getSupported('canvas'); if false, fall back tolove.image.newImageDatasoftware rendering.
Limitations & Gotchas
- MSAA vs. readable:
msaa > 0andreadable = trueare mutually exclusive. Choose antialiasing or CPU readback. - Nested canvases: Supported, but each
setCanvasswitch incurs a GPU state change. Keep nesting shallow. - Mobile GPUs: Some (especially older Adreno/Mali) reject readable render targets. Always gate features with
canvas:isReadable(). - Coordinate drift: If you draw to canvas while a camera transform is active, the canvas receives transformed coordinates. Reset with
love.graphics.origin()first.
Recovery Options
If a canvas creation fails (returns nil plus error), fall back to a smaller size or disable the feature:
local canvas, err = love.graphics.newCanvas(w, h, { readable = true })
if not canvas then
print('Canvas creation failed: ' .. err)
-- fallback: render directly to screen, skip post-processing
useCanvas = false
end
If newImageData() errors on mobile, catch with pcall and disable screenshot/analysis features gracefully.0 replies
A thoughtful contribution can make all the difference. Be the first to share one.