love2d-crt-scanline-shader-canvas
LOVE 2D CRT Scanline Shader Canvas: A Working Introduction
Let me tell you about the moment I gave up on "just pixel art." I'd spent an afternoon nudging a 16x16 sprite to match the exact palette of a 1989 arcade board, animating it at fifteen frames per second, aligning every pixel to a strict integer grid. On my LCD it looked... wrong. Not broken — wrong in the way a photograph of food is wrong compared to the meal.
The pixels were too crisp. The blacks were too uniform. The colors refused to bloom into their neighbors the way they used to when a cathode ray was dragging them out of phosphors at sixty hertz.
Most of what we remember as "the look of old games" is actually the look of an old display arguing with the signal, and you can't reproduce that with art direction alone. You need a shader.
This article is an introduction to one of the most common shader effects you can wire into a LOVE 2D game: a CRT scanline filter applied as a full-screen post-processing pass on a canvas. It is not a deep dive into the physics of phosphor decay or the math behind shadow mask geometry. It's a map. My goal is to give you enough vocabulary, enough conceptual structure, and enough pointers to the right corners of the LOVE wiki and the broader shader community that the next time you sit down to write love.graphics.newShader, you know what you're reaching for and why.
1. Why CRT Shaders Matter for Pixel Art Games
Why does a pixel art game, scaled up with nearest-neighbor filtering and shipped on a modern display, still feel subtly wrong to anyone who grew up with arcade cabinets? That's fine. It preserves the integer pixel grid, prevents the blurry mush that bilinear scaling creates, and respects the original art. But it also leaves a gap between intention and perception. The art was designed under an assumption — that horizontal lines of pixels would be slightly darkened by the gap between scanlines, that bright pixels next to dark pixels would bleed across the boundary, that the whole image would carry a faint warmth from the analog signal path. Without those assumptions, the art reads differently. It reads correctly, but not familiarly.
A CRT shader closes that gap. The minimum useful version of the effect introduces dark horizontal bands at regular vertical intervals — the scanlines. A slightly more ambitious version adds a phosphor mask, where each "pixel" on the simulated screen is actually three sub-pixels of red, green, and blue. A still more ambitious version curves the image to mimic the convex glass of a tube monitor, adds chromatic aberration toward the edges, and blooms bright pixels outward. Each of these is its own shader pass, or a configurable feature of a single uber-shader, and each one nudges the rendered output closer to the analog reference. The cumulative effect is striking, even if no single element by itself feels dramatic.
2. The Canvas and Shader Pipeline in LOVE 2D
Why does binding a shader to each sprite produce the wrong result, while binding it once to a canvas containing those same sprites produces the right one? The answer fits in two steps — render the game to an off-screen target first, then re-render that target to the screen with the shader bound. The off-screen target is a Canvas object, which is essentially a framebuffer object that the LOVE renderer treats as a draw destination. The pattern looks like this: at the start of your frame, you call love.graphics.setCanvas(target) and draw your entire scene as normal. At the end of the frame, you call love.graphics.setCanvas() with no argument to return to the default backbuffer, you bind your CRT shader with love.graphics.setShader(crt), and you draw the canvas as a textured quad covering the screen. The shader runs once per output pixel, samples the canvas at the correct source coordinate, and produces the final color.
The reason this two-step dance matters is that shaders operate on whatever pixels are being written, and you almost never want every individual sprite to be filtered through a CRT pass. You want the composed frame — the background, the entities, the UI, all flattened together — to go through the filter as one image, the way an actual CRT received a composite signal. Without the canvas intermediate step, you would either be filtering each draw call separately (which produces wrong results) or trying to reason about render order in a way that no engine should ask of you.
Here is the smallest possible skeleton, just to make the shape concrete:
local canvas
local crtShader
function love.load()
canvas = love.graphics.newCanvas(320, 240)
crtShader = love.graphics.newShader([[
extern vec2 screen_size;
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
vec4 px = Texel(tex, tc);
float scan = sin(tc.y * screen_size.y * 3.14159) * 0.5 + 0.5;
px.rgb *= 0.75 + 0.25 * scan;
return px * color;
}
]])
end
function love.draw()
love.graphics.setCanvas(canvas)
love.graphics.clear()
-- draw your game here at native resolution
love.graphics.setCanvas()
crtShader:send("screen_size", {love.graphics.getWidth(), love.graphics.getHeight()})
love.graphics.setShader(crtShader)
love.graphics.draw(canvas, 0, 0, 0,
love.graphics.getWidth() / canvas:getWidth(),
love.graphics.getHeight() / canvas:getHeight())
love.graphics.setShader()
end
That is the entire architecture in twenty lines. Everything else — better scanline shapes, color masks, curvature, glow — is a refinement of the effect function inside the shader. The host code doesn't need to change.
3. Anatomy of a Scanline Effect
The most convincing part of a CRT effect is also the cheapest to write — a single sine wave along the vertical axis of the framebuffer already does most of the visual heavy lifting. You take the texture coordinate's y component, multiply it by some frequency (typically twice the canvas height, so each source pixel row gets one bright and one dark band), pass it through a sine, normalize the result to the zero-to-one range, and then use it to modulate the brightness of the sampled color. That's exactly what the skeleton above does. The result is a horizontal banding pattern that gets denser as you move further from the eye of the perceiver, because the sine cycles run at output-pixel resolution rather than source-pixel resolution.
There are several refinements that immediately matter once you start staring at the output. The first is gamma. Real CRTs operated in a roughly gamma-2.2 color space, and darkening a linear-space color by 25 percent is not the same as darkening a gamma-encoded color by 25 percent. If you want the dark bands to feel like the dark gaps between phosphor rows on a real tube, convert to linear, apply the scanline modulation, and convert back. The cost is two pow calls per pixel, which is negligible on modern hardware.
The second refinement is non-uniform scanline shape. A pure sine produces equally bright and equally dark bands, but real scanlines were sharper and more localized. A more accurate shape is a peaked Gaussian centered on each source pixel row, falling off into near-black between rows. You can compute it analytically with a single exponential, or you can approximate it with a smoothstep over the fractional part of the y coordinate. Either choice gives you a knob — width, falloff sharpness — that lets you tune the effect from "very subtle" to "wartime broadcast".
The third refinement is per-channel timing. On a slot-mask or aperture-grille tube, the three color components occupied physically distinct stripes within each pixel cell. You can simulate this by sampling the source texture at three slightly offset coordinates, one per channel, and combining the results. The horizontal offset has to scale with your effective output pixel size or it will look broken at non-integer scale factors. This is where a lot of homebrew CRT shaders go wrong: they hard-code an offset that only looks correct at 4x scale and falls apart at every other size.
4. Beyond Scanlines: Mask, Curvature, Bloom
A full-fat CRT shader stacks several effects in series. The order matters. A reasonable order, from cheap to expensive and from "structural" to "atmospheric", is roughly: barrel distortion of the input coordinate, source sampling with chromatic aberration, phosphor mask multiplication, scanline modulation, gamma correction, vignette, and finally an additive bloom from a downsampled and blurred copy of the frame. Each of these can be toggled or scaled independently, which is why most production CRT shaders end up with a dozen extern uniforms and a calibration menu.
You don't need to implement all of these from scratch. The libretro project's glsl-shaders repository contains dozens of reference CRT implementations, ranging from the iconic CRT-Royale to lighter weight options like crt-easymode, and each one is licensed permissively enough that you can port the math to LOVE's shader dialect without legal friction. The LOVE shader language is a subset of GLSL with some macro wrapping (the effect function, the Texel function), so porting is mostly a matter of replacing the entry point and the texture lookup. Everything else — the math, the uniforms, the structure — translates directly.
5. Performance Considerations
There is one persistent worry with full-screen post-processing in LOVE, and that's fill rate. A CRT shader runs once per output pixel, and if your shader does several texture samples, several pow calls, and a bloom pass, you can easily push past the point where a low-end laptop GPU can sustain sixty frames per second at 1080p. There are three standard mitigations. The first is to render your game at native low resolution, apply the shader at a moderate intermediate resolution (say, 720p), and let the OS scaler take it to fullscreen. The second is to split the shader into structural and atmospheric passes and let the player disable the expensive ones. The third is to precompute lookup tables — for example, the scanline modulation pattern can be baked into a small texture that the shader samples instead of computing live.
The official LOVE 2D wiki page on Shader documents the uniform-passing API and the GLSL subset in detail, and is worth reading once end to end before you start optimizing. The page on Canvas covers the off-screen rendering primitive that the whole pattern depends on, including how mipmap settings and filter modes interact with the shader pass. Both pages are concise enough to absorb in a single sitting and dense enough that re-reading them after a week of practical work tends to reveal something you missed the first time.
6. Where To Go Next
If this introduction has done its job, you now have a mental model of the pipeline — canvas first, shader second, screen last — and a vocabulary for the individual effects that stack into a convincing CRT look. The next steps depend on your goals. If you're building a small game and want the effect mostly as flavor, the twenty-line skeleton above is enough. Tune the scanline strength until it stops feeling like an apology and starts feeling like an enhancement, then leave it alone. If you're building a larger project, or one that targets a wide range of displays, the right move is to study one of the libretro reference shaders, port a stripped-down version of it, and expose three or four user-facing knobs (scanline intensity, mask intensity, curvature, bloom). If you're building an emulator front end or a retro game compilation, you will eventually want the full shadow mask, the per-system geometry, and the calibration menu, and at that point the libretro work becomes a starting point rather than a reference.
The thing to remember is that CRT shaders are an aesthetic choice with a technical floor. You can't get the look without the canvas-and-shader plumbing, but the plumbing itself is shallow. Most of the depth lives in the artistic decisions: how strong, how dark, how curved, how warm. Those decisions are easier to make once you have something running, even something rough. Build the skeleton first. Tune later. The pixels will thank you.