Feedback Buffers in Love2D 11.x: Simulate State with Pixel Shaders
Love2D 11.x has no compute shaders, but you can simulate per-pixel state by ping-ponging float canvases through a full-screen quad shader. Includes a worked echo-buffer example and mobile fallback checks.
30 Mar 2026, 08:03 UTC

The problem: your effect needs memory, but Love2D has no compute shader
You want a trailing echo, a fluid density field, or a cellular automaton where each pixel's next value depends on its previous value. Love2D 11.x exposes pixel shaders, but it does not expose compute shaders, uniform buffer objects, or shader storage buffer objects. You cannot dispatch a shader to read and write arbitrary memory. The practical alternative is a feedback buffer: render a full-screen quad whose fragment shader reads the previous frame's canvas and writes the updated state to another canvas, then swap roles next frame.
How ping-pong canvases act as simulation state
Create two canvases with a floating-point format such as rgba32f or rgba16f. In love.draw(), bind canvas A as the render target, draw a full-screen quad textured with canvas B, run the shader, then swap A and B for the next frame. Because the shader reads from one canvas while writing to the other, the previous frame's state persists across frames without CPU involvement.
Worked example: a decaying echo buffer
This example keeps a fading trail of whatever is drawn to the scene. Each frame, the scene is blended into the feedback buffer with a decay factor, so older frames gradually disappear.
Shader: echo.frag
#version 300 es
precision highp float;
in vec2 vTexCoord;
uniform sampler2D feedback;
uniform sampler2D scene;
uniform float decay;
out vec4 fragColor;
void main() {
vec4 previous = texture(feedback, vTexCoord);
vec4 current = texture(scene, vTexCoord);
vec4 blended = mix(previous * decay, current, 0.4);
fragColor = blended;
}
Lua: setup and draw loop
local g = love.graphics
local width, height = g.getDimensions()
local sceneCanvas = g.newCanvas(width, height)
local function newFeedbackCanvas()
local canvas = g.newCanvas(width, height, {format = "rgba32f", msaa = 0})
if not canvas then
canvas = g.newCanvas(width, height, {format = "rgba16f", msaa = 0})
end
return canvas
end
local feedbackA = newFeedbackCanvas()
local feedbackB = newFeedbackCanvas()
local echoShader = g.newShader("echo.frag")
g.setCanvas(feedbackA)
g.clear()
g.setCanvas(feedbackB)
g.clear()
g.setCanvas()
function love.draw()
g.setCanvas(sceneCanvas)
g.clear()
drawScene()
g.setCanvas()
g.setCanvas(feedbackA)
g.clear()
g.setShader(echoShader)
echoShader:send("feedback", feedbackB)
echoShader:send("scene", sceneCanvas)
echoShader:send("decay", 0.92)
g.rectangle("fill", 0, 0, width, height)
g.setShader()
g.setCanvas()
g.draw(feedbackA, 0, 0)
feedbackA, feedbackB = feedbackB, feedbackA
end
Verifying the overhead with getStats()
Call local stats = love.graphics.getStats() at the end of love.draw(). For the echo buffer above you should expect roughly one draw call for the scene, one for the feedback quad, and one for the final blit. canvasswitches counts every canvas bind and unbind, so each setCanvas() in the loop adds to the total. Each switch is a GPU synchronization point. If you stack many feedback effects, consider combining them into a single shader pass with multiple render targets instead of cascading ping-pong pairs.
Trade-offs and mobile fallbacks
- Floating-point render targets are not guaranteed on mobile.
love.graphics.newCanvas({format = "rgba32f"})can returnnilon iOS and Android. Check the return value and fall back torgba16for pack data intorgba8manually. - No UBOs or SSBOs. You cannot upload large state buffers directly. Pass parameters as uniforms or encode them in small textures.
- Shader objects are expensive to create. Call
love.graphics.newShader()once inlove.load()and reuse it. Creating a shader per frame will stall the pipeline. - Canvas switches add up. Watch
love.graphics.getStats().canvasswitches. Keep the number of feedback canvases low.
Actionable checklist
- Query
love.graphics.getSystemLimits()at startup and logtexturesizeto confirm your canvas resolution is supported. - Create feedback canvases and shaders in
love.load(); never insidelove.draw(). - Test
newCanvas({format = "rgba32f"})on target mobile devices; implement thergba16ffallback path before shipping. - Profile a representative frame with
love.graphics.getStats()and watchcanvasswitchesgrow as you add passes. - Validate GLSL 300 es shaders with
glslangValidatorbefore embedding them in Lua strings;newShader()only reports the first linker error.
Love2D 11.x gives you float canvases and full-screen quad shaders, which is enough for many simulation-style effects. The engineering work is managing the ping-pong state, choosing a format your target devices can actually render to, and keeping canvas switches to a minimum.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.