SDL3 Sprite Jitter Fix: Rounding Float Coordinates for Pixel-Perfect Rendering

The ball looks fine in screenshots but glitches on video — here’s why

Those horizontal lines tearing in and out of your sprite while it moves are a sub-pixel jitter problem. SDL3 moved every rendering call to floating-point coordinates — useful for smooth animation — but it means that if your ball position is something like 152.7f, that fractional value gets passed straight to the GPU as the destination rectangle’s X or Y. When a logical presentation scales that canvas up, a 0.7 logical pixel becomes 1.4 or 2.1 display pixels, and the GPU has to decide which rows to include. That decision changes every frame as the float creeps forward, producing rows that flicker in and out at the sprite edges.

Why screenshots look perfect

A screenshot captures one frozen framebuffer. The jitter is a motion artifact — it only shows up as the inconsistency between consecutive frames. Any single frame looks complete; it’s the difference between them that your eye catches as flicker. That’s also why video reveals it and a screenshot doesn’t.

The fix: round to whole pixels at draw time

Keep your game state in floats. Position, velocity, all physics — stay in float so your movement math stays precise. The snapping happens only at the moment you build the SDL_FRect for the render call:

/* game state stays float */
float ball_x = 152.7f;
float ball_y = 84.3f;

/* snap to integer only when rendering */
SDL_FRect dst = {
    SDL_roundf(ball_x),
    SDL_roundf(ball_y),
    (float)texture_width,
    (float)texture_height
};
SDL_RenderTexture(renderer, texture, NULL, &dst);

SDL_roundf rounds to the nearest whole number. You can also use floorf from <math.h> if you prefer always rounding down. The destination rect’s X and Y need to land on a whole logical pixel before the scale multiplier is applied — that’s the critical part.

Why not just store position as int?

If the ball moves at, say, 1.5 logical pixels per frame, an int position discards the fractional part each frame, and the ball stutters at a different level — it covers 1 pixel most frames and 2 pixels occasionally, giving uneven motion. Float position plus integer snap at render time is the standard approach for pixel-art games. You get accurate physics and clean pixels.

What SDL_LOGICAL_PRESENTATION_INTEGER_SCALE actually does

Your code calls SDL_SetRenderLogicalPresentation with SDL_LOGICAL_PRESENTATION_INTEGER_SCALE, which scales the 320×180 logical canvas by the largest whole-number multiplier that fits the window. That keeps the canvas itself sharp — no blurred edges from a fractional upscale. What it does not do is round the positions of objects drawn within that canvas. A sprite at logical 152.7 stays at 152.7 in logical space; integer scale applies afterward. The sub-pixel error is still there going in, and the integer multiplier makes it larger coming out.

Scale mode: SDL_SCALEMODE_NEAREST vs SDL_SCALEMODE_PIXELART

You’re already calling SDL_SetTextureScaleMode(texture, SDL_SCALEMODE_PIXELART). One thing worth knowing: SDL_SCALEMODE_PIXELART was introduced in SDL 3.4.0 and, somewhat counterintuitively, uses linear filtering internally in the OpenGL and Vulkan backends rather than nearest-neighbor. It’s designed to produce cleaner-looking edges at integer scales by blending slightly at the boundary. If you’re on an SDL3 build older than 3.4.0, the enum doesn’t exist and the compiler will refuse it — fall back to SDL_SCALEMODE_NEAREST in that case. Either mode still requires the rounding fix to prevent jitter; scale mode addresses filter quality, not coordinate precision.

VSync and how it differs from this problem

Enabling vsync with SDL_SetRenderVSync(renderer, 1) addresses a separate artifact: the framebuffer being swapped mid-scan, producing a horizontal tear that drifts across the screen continuously. That’s worth doing, but it’s not the cause of what you’re seeing. Sub-pixel jitter appears only during motion and disappears when the sprite is stationary. A drifting scan-line tear is present regardless of movement. They can look similar in a video but have different causes and different fixes.

  • Call SDL_roundf() or floorf() on X and Y before building the destination SDL_FRect.
  • Keep game-state position as float; snap only at render time.
  • Integer logical presentation controls canvas upscale quality, not in-canvas object positions.
  • SDL_SCALEMODE_PIXELART requires SDL 3.4.0 or newer; use SDL_SCALEMODE_NEAREST on older builds.
  • VSync is worth enabling separately — it fixes scan-line tearing, not sub-pixel jitter.

Sources


Similar Posts