[SDL3] Advice on vsync and frame times

Hi all,

I have a game where I am allowing the player to enable/disable vsync, and I am just observing some behaviour with the frame times and am wondering if anyone could offer me some insight.

I am using the “main” function and while-loop API for SDL, and I am trying to run the game at a steady 60 FPS. When I have vsync turned on, the DT between each frame (“time_dt”) fluctuates times in the range of 16.58 - 17.14 ms.

If I add a wait function below the render, it stabilises it (see “DO_SLEEP”, I know there are functions like SDL_DelayPrecise but I’m just spinning the CPU for this example). Adding that line will keep DT values in a range very close to 16.666 ms.

My question is, are the fluctuations like this in frame times normal when vsync is turned on, or is there something stupid I am missing or doing wrong? Any help would be much appreciated!

Also one other question: When vsync is turned on, I get a bit of input lag for the events. Can this be removed also? Weirdly if i set the renderer to “opengl” it seems to get rid of the lag.

My current specs:

  • Mac OS X (but I also get this behaviour on windows).
  • SDL version 3.15

Here’s the example code:

/*
  main.cpp
*/

#include <cstdlib>

#include <SDL3/SDL.h>
#include <SDL3/SDL_main.h>
  
constexpr Uint64 NS_PER_FRAME = 16666666;
constexpr Uint64 LOG_EVERY_NS = 1000000000;
constexpr int VSYNC = 1;
constexpr bool DO_SLEEP = false;

/**
 * SDL Entrypoint.
 */
int main(int argc, char *args[]) {

  // For Mac OS X.
  SDL_SetMainReady();

  // Renderer.
  //SDL_SetHint(SDL_HINT_RENDER_DRIVER, "opengl");

  // Init SDL.
  if (!SDL_Init(SDL_INIT_VIDEO)) {
    return EXIT_FAILURE;
  }

  // Set exit func.
  atexit(SDL_Quit);

  // Initialize the window.
  SDL_Window *window;
  SDL_Renderer *renderer;
  if (!SDL_CreateWindowAndRenderer("My App", 500, 500, SDL_WINDOW_RESIZABLE, &window, &renderer)) {
    SDL_Log("Could not create window. %s", SDL_GetError());
    SDL_Quit();
    return EXIT_FAILURE;
  }

  // Create 1x1 texture.
  SDL_Texture *texture = SDL_CreateTexture(renderer, SDL_PIXELFORMAT_RGB24, SDL_TEXTUREACCESS_STREAMING, 1, 1);
  if (texture == nullptr) {
    SDL_Log("Could not create texture. %s", SDL_GetError());
    SDL_DestroyRenderer(renderer);
    SDL_DestroyWindow(window);
    SDL_Quit();
    return EXIT_FAILURE;
  }

  // Set texture to be red.
  int pitch = 0;
  uint8_t* ptr;
  if (SDL_LockTexture(texture, nullptr, reinterpret_cast<void**>(&ptr), &pitch)) {
    ptr[0] = 255;
    ptr[1] = 0;
    ptr[2] = 0;
    SDL_UnlockTexture(texture);
  }

  // Log renderer properties.
  const char* name = SDL_GetRendererName(renderer);
  if (name != nullptr) {
    SDL_Log("Renderer: %s", name);
  }

  // Set vsync value.
  if (!SDL_SetRenderVSync(renderer, VSYNC)) {
    SDL_Log("Could not set vsync: %s", SDL_GetError());
  }

  // Timer.
  Uint64 last_time = SDL_GetTicksNS();
  Uint64 log_at = 0;

  // Render rect.
  SDL_FRect texture_rect { 0.0f, 0.0f, 100.0f, 100.0f };

  // Loop until the user quits.
  bool quit = false;
  while (!quit) {

    // Get elapsed time.
    const Uint64 curr_time = SDL_GetTicksNS();
    Uint64 time_dt = curr_time - last_time;
    last_time = curr_time;

    // Log elapsed time every N nanoseconds.
    log_at += time_dt;
    if (log_at >= LOG_EVERY_NS) {
      SDL_Log("DT: %llu", time_dt);
      log_at = 0;
    }

    // Poll events.
    SDL_Event event;
    while (SDL_PollEvent(&event)) {
      switch (event.type) {
        case SDL_EVENT_QUIT: {
          quit = true;
          break;
        }
        case SDL_EVENT_KEY_DOWN: {
          if (event.key.repeat == 0) {
            switch (event.key.key) {
              case SDLK_UP: {
                texture_rect.y -= 10.0f;
                break;
              }
              case SDLK_DOWN: {
                texture_rect.y += 10.0f;
                break;
              }
              case SDLK_LEFT: {
                texture_rect.x -= 10.0f;
                break;
              }
              case SDLK_RIGHT: {
                texture_rect.x += 10.0f;
                break;
              }
            }
          }
        }
      }
    }

    // Render.
    SDL_SetRenderDrawColor(renderer, 128, 128, 128, 255);
    SDL_RenderClear(renderer);
    SDL_RenderTexture(renderer, texture, nullptr, &texture_rect);
    SDL_RenderPresent(renderer);

    // Sleep for remaining frame duration.
    if (DO_SLEEP) {
      const Uint64 next_frame_time = curr_time + NS_PER_FRAME;
      while (SDL_GetTicksNS() < next_frame_time) {}
    }
      
  }

  SDL_Log("Exiting");
  SDL_DestroyTexture(texture);
  SDL_DestroyRenderer(renderer);
  SDL_DestroyWindow(window);
  SDL_Quit();
  return EXIT_SUCCESS;

}

Yes, this fluctuation is expected. A better solution than adding a delay is to decouple your game logic rate from its rendering rate (which should be the monitor’s refresh rate when vsync is enabled). Also, bear in mind that there are monitors whose refresh rate is not 60hz! I used to have one that was 59hz!

The input lag is because, with vsync enabled, A) your app is waiting for SDL_RenderPresent() to return, which means it isn’t processing input (or doing anything else), and B) your app will always be at least one frame behind what is showing on screen (possibly/probably more).

3 Likes

Thanks a lot for the advice!

In that case I definitely need to update my logic a bit. The movement is pretty pixel-based and is trying to move one pixel every 16.6666 ms, which of course will not align with the monitor and lead the player to sometimes move 2 pixels or 0 pixels.

I actually want to prioritize smooth-looking movement rather than correct movement here (It’s a puzzle game where movement speed is less important), so I may try and tie it somewhat to the monitor refresh rate!

Unrelated, but when I use SDL_DelayPrecise below SDL_RenderPresent with vsync on to “smoothen” it to 16.66 ms, it actually seems to reduce the input lag by one frame and I have no idea why. It seems counter-intuitive. If anyone else is experiencing this let me know.