Embedded HandbookApps
All sections › XVII. Systems programming in C
· level intermediate · 12 min read
Open in the interactive handbook

149. Graphics and games in C: SDL2, the game loop, OpenGL

A game is the same kind of real-time program as firmware with a display (section 22а): poll the input, recompute the state, draw a frame, and do it sixty times a second. The difference is scale: a million pixels instead of a thousand, and a graphics card that draws them for the program. This section is about two layers: SDL2, which gives you a window, input, sound and simple drawing on any system, and OpenGL, which hands drawing to the graphics card through shaders. Both programs were run in stm-sys:1 without a screen: SDL on the dummy driver, OpenGL through offscreen and the Mesa software rasterizer; the frames were saved as BMP and checked pixel by pixel (_verify/sys149/).

Terms used in the documentation

Term Meaning
game loop the game loop: input → update → frame
fixed timestep updating in steps of a fixed length
frame rate (FPS), frame time how often frames come and how long one takes
renderer, surface, texture the drawing object, a pixel buffer in memory, an image for drawing
event an event (a key, the mouse, closing the window)
GPU, graphics pipeline the graphics processor, the chain of drawing stages
vertex, fragment a corner of a triangle, a fragment (a pixel to be)
shader (GLSL) a shader: a program for the graphics card
VAO, VBO the description and the buffer of vertices
rasterization rasterization: from triangles to pixels
software rasterizer (llvmpipe) rasterization in software on the CPU
double buffering, vsync drawing into a hidden buffer, synchronization with the display refresh

149.1 What a game is from the program's point of view

    while (SDL_GetTicks() - 0 < 1000 && frames < 100000) {  /* одна секунда игрового времени */
        SDL_Event e; while (SDL_PollEvent(&e)) if (e.type == SDL_QUIT) goto done;   /* 1. ввод */
        Uint32 now = SDL_GetTicks(); lag += now - prev; prev = now;
        while (lag >= STEP_MS) { update(&b, STEP_MS / 1000.0); lag -= STEP_MS; updates++; }   /* 2. догнать физику */
        render(r, &b); frames++;                            /* 3. нарисовать */
        SDL_Delay(1);
    }

Three steps in the loop — and the main question: what does motion depend on. If you move the ball by a constant step every frame, the speed of the game depends on the frame rate: on a fast machine the ball flies faster. If you move it by the frame time, then on a long frame the ball jumps through the wall. The solution is a fixed timestep: the physics is computed in 10 ms portions as many times as needed to catch up with real time (lag), and as many frames are drawn as there is time for:

SDL 2.26.5, видеодрайвер dummy
за 1 с: обновлений физики 99 (шаг 10 мс), кадров 921; мяч в (169, 139)
в кадре 256 жёлтых пикселей (мяч 16×16 = 256)

99 updates per second with 921 frames: the physics runs at its own pace regardless of drawing, and on any machine the ball covers the same 120 pixels in a second. update gets dt only from the step, not from the frame:

static void update(struct ball *b, double dt)             /* физика — только от шага, не от кадра */
{
    b->x += b->vx * dt; b->y += b->vy * dt;
    if (b->x < 8 || b->x > W - 8) b->vx = -b->vx;
    if (b->y < 8 || b->y > H - 8) b->vy = -b->vy;
}

A fixed timestep has two traps. The first: if update takes longer than the step (a slow machine, a stop in the debugger), lag grows faster than the loop can work it off: the catching-up eats the whole CPU and never catches up. That is why lag is capped from above — if (lag > 250) lag = 250; — and after a pause the game simply skips the time. The second: with 921 frames per 99 physics steps, nine or ten frames in a row draw the same position, and on a fast screen the motion stutters. Draw an intermediate position instead: x_prev + (x − x_prev) · lag / STEP_MS, where x_prev is the position before the last step.

The same principle is used in firmware: a timer gives a tick, processing runs on the tick, and the screen is updated when it can be (section 22а).

The game loop: input, accumulated time, physics in fixed steps as many as needed, a frame when there is time


149.2 SDL2: a window, input, drawing

SDL hides the operating system: one API for Windows, Linux, macOS, Android and consoles. SDL_Init, SDL_CreateWindow, SDL_CreateRenderer — and you have somewhere to draw; events come from SDL_PollEvent in a loop; SDL_GetTicks gives milliseconds since startup; SDL_Delay yields the CPU.

static void render(SDL_Renderer *r, const struct ball *b)
{
    SDL_SetRenderDrawColor(r, 20, 30, 60, 255); SDL_RenderClear(r);
    SDL_Rect q = { (int)b->x - 8, (int)b->y - 8, 16, 16 };
    SDL_SetRenderDrawColor(r, 250, 200, 40, 255); SDL_RenderFillRect(r, &q);
    SDL_RenderPresent(r);
}

The frame is drawn in a hidden buffer and shown as a whole (SDL_RenderPresent) — double buffering, no flicker. Without a screen, SDL can use the dummy driver (the container has x11 wayland KMSDRM offscreen dummy evdev): everything works, and the frame can be read with SDL_RenderReadPixels and saved with SDL_SaveBMP. The frame contains exactly 256 pixels of the ball's color — 16 × 16, not one more. This is how a game is checked automatically, without a person at the screen.

Add-ons: SDL_image — PNG and JPEG, SDL_ttf — fonts, SDL_mixer — sound, SDL_net — sockets. Ports of classic games and emulators are built on SDL; it is also used as a layer under OpenGL and Vulkan.


149.3 OpenGL: the pipeline and shaders

A graphics card does not draw a "ball" — it draws triangles. The program gives it vertices, and the card runs them through the pipeline: the vertex shader decides where a point is on the screen; the rasterizer turns the triangle into fragments; the fragment shader decides the color of each one. Shaders are programs in GLSL, compiled at run time:

static const char *vs_src =
    "#version 330 core\n"
    "layout(location = 0) in vec2 pos;\n"                    /* вершинный шейдер: где точка */
    "void main() { gl_Position = vec4(pos, 0.0, 1.0); }\n";
static const char *fs_src =
    "#version 330 core\n"
    "out vec4 color;\n"                                      /* фрагментный шейдер: какого цвета пиксель */
    "void main() { color = vec4(1.0, 0.8, 0.2, 1.0); }\n";

The vertices are put into a buffer on the graphics card (VBO), and their layout is described (VAO); one command draws:

    float tri[] = { -0.5f, -0.5f, 0.5f, -0.5f, 0.0f, 0.5f };  /* три вершины в координатах −1…1 */
    glBufferData(GL_ARRAY_BUFFER, sizeof tri, tri, GL_STATIC_DRAW);   /* вершины — в память видеокарты */
    glUseProgram(prog); glDrawArrays(GL_TRIANGLES, 0, 3);      /* конвейер: вершины → растеризация → фрагменты */

The OpenGL context is created by SDL (SDL_GL_CreateContext), and the OpenGL functions above version 1.1 are loaded by GLEW. There is no graphics card in the container, but there is Mesa with the llvmpipe software rasterizer, and the SDL offscreen driver gives it a surface:

OpenGL 4.5 (Core Profile) Mesa 22.3.6, llvmpipe (LLVM 15.0.6, 256 bits)
треугольник: 38400 пикселей цвета (255,204,51) из 307200 (ожидается около 38400)

The frame was read back with glReadPixels: the triangle with vertices (−0.5, −0.5), (0.5, −0.5), (0, 0.5) covers exactly one eighth of the 640 × 480 screen — 38400 pixels out of 307200, and the rasterizer drew exactly that many. The color vec4(1.0, 0.8, 0.2) became (255, 204, 51): 0.8 × 255 = 204.

The OpenGL pipeline from the experiment: three vertices in coordinates from minus one to one, a vertex shader with gl_Position, the rasterizer, a fragment shader with the color vec4 one, zero point eight, zero point two, and a 640 by 480 frame; a triangle with base and height 1 in a 2 by 2 field covers one eighth — 38400 pixels out of 307200, the color in the frame is 255, 204, 51

The fixed pipeline of old OpenGL (glBegin/glVertex) has been removed from modern profiles: without shaders nothing is drawn. Today, next to OpenGL there is Vulkan (lower level, more code) and Metal (Apple); for a game in C it is simpler to take a ready-made engine or SDL with its renderer.


149.4 A whole game

The minimal set: a loop with a fixed timestep, the game state in one struct, input parsed into events, drawing from the state, sound through SDL_mixer, a high-score table in a file. Mistakes that repeat from project to project: physics tied to the frame; drawing inside update; everything in a two-thousand-line main; SDL_Delay(16) instead of measuring time (on a slow machine the game slows down, on a fast one it burns the CPU). Named patterns: the game loop, the component, the state machine (menu → game → pause), the object pool (section 72 — a pool of nodes) — the same as in firmware, along with the function tables familiar from section 146.


149.5 Pitfalls

Symptom Cause
the game is faster on a fast machine the physics step is tied to the frame — use a fixed timestep
the ball flies through the wall a long frame and one big dt — several steps of 10 ms
after a pause the game "freezes" and loads the CPU lag without an upper bound: the catching-up never catches up
motion stutters at high frame rates the last physics position is drawn instead of an intermediate one by lag / STEP_MS
100 % CPU while idle no SDL_Delay or vsync
the window is "not responding" SDL_PollEvent is not called every frame
a black screen in OpenGL 3.3+ no shaders: the fixed pipeline has been removed
glGenVertexArrays — "undefined reference" or a crash functions above 1.1 without a loader (GLEW)
SDL_GL_CreateContext: Invalid window the dummy driver cannot do OpenGL — use offscreen
the color is wrong vec4(1.0, 0.8, 0.2) → (255, 204, 51); bytes and fractions get mixed up
there is no graphics card in the container Mesa llvmpipe: slow but exact — good enough for checks

149.6 Summary

Part XVII is complete.

Full sources

loop_sdl.c — a game loop in SDL2 with a fixed update step.

/* Раздел 149: игровой цикл на SDL2 с фиксированным шагом обновления и рисованием «как успеем»;
   без экрана — драйвер dummy, кадр сохраняется в BMP. Мяч летает и отскакивает от стенок. */
#include <stdio.h>
#include <stdlib.h>
#include <SDL2/SDL.h>

#define W 320
#define H 200
#define STEP_MS 10                                        /* шаг физики: 100 обновлений в секунду */

struct ball { double x, y, vx, vy; };

static void update(struct ball *b, double dt)             /* физика — только от шага, не от кадра */
{
    b->x += b->vx * dt; b->y += b->vy * dt;
    if (b->x < 8 || b->x > W - 8) b->vx = -b->vx;
    if (b->y < 8 || b->y > H - 8) b->vy = -b->vy;
}

static void render(SDL_Renderer *r, const struct ball *b)
{
    SDL_SetRenderDrawColor(r, 20, 30, 60, 255); SDL_RenderClear(r);
    SDL_Rect q = { (int)b->x - 8, (int)b->y - 8, 16, 16 };
    SDL_SetRenderDrawColor(r, 250, 200, 40, 255); SDL_RenderFillRect(r, &q);
    SDL_RenderPresent(r);
}

int main(void)
{
    if (SDL_Init(SDL_INIT_VIDEO) < 0) { printf("SDL_Init: %s\n", SDL_GetError()); return 1; }
    SDL_Window *w = SDL_CreateWindow("ball", 0, 0, W, H, 0);
    SDL_Renderer *r = SDL_CreateRenderer(w, -1, SDL_RENDERER_SOFTWARE);
    printf("SDL %d.%d.%d, видеодрайвер %s\n", SDL_MAJOR_VERSION, SDL_MINOR_VERSION, SDL_PATCHLEVEL, SDL_GetCurrentVideoDriver());
    struct ball b = { 50, 50, 120, 90 };                   /* пикселей в секунду */
    Uint32 prev = SDL_GetTicks(); double lag = 0;
    int frames = 0, updates = 0;
    while (SDL_GetTicks() - 0 < 1000 && frames < 100000) {  /* одна секунда игрового времени */
        SDL_Event e; while (SDL_PollEvent(&e)) if (e.type == SDL_QUIT) goto done;   /* 1. ввод */
        Uint32 now = SDL_GetTicks(); lag += now - prev; prev = now;
        while (lag >= STEP_MS) { update(&b, STEP_MS / 1000.0); lag -= STEP_MS; updates++; }   /* 2. догнать физику */
        render(r, &b); frames++;                            /* 3. нарисовать */
        SDL_Delay(1);
    }
done:
    printf("за 1 с: обновлений физики %d (шаг %d мс), кадров %d; мяч в (%.0f, %.0f)\n", updates, STEP_MS, frames, b.x, b.y);
    SDL_Surface *s = SDL_CreateRGBSurface(0, W, H, 32, 0, 0, 0, 0);
    SDL_RenderReadPixels(r, NULL, s->format->format, s->pixels, s->pitch);
    SDL_SaveBMP(s, "/tmp/frame.bmp");                     /* кадр — в файл, раз экрана нет */
    Uint32 *px = s->pixels; int yellow = 0;
    for (int i = 0; i < W * H; i++) if ((px[i] & 0xffffff) == 0xfac828) yellow++;
    printf("в кадре %d жёлтых пикселей (мяч 16×16 = 256)\n", yellow);
    SDL_FreeSurface(s); SDL_DestroyRenderer(r); SDL_DestroyWindow(w); SDL_Quit();
    return 0;
}

gl_tri.c — a minimal OpenGL program: a window, shaders, a triangle.

/* Раздел 149: минимальная программа OpenGL — окно SDL с контекстом, шейдеры, треугольник.
   Без экрана работает через SDL_VIDEODRIVER=offscreen и программный растеризатор Mesa (llvmpipe). */
#include <stdio.h>
#include <stdlib.h>
#include <SDL2/SDL.h>
#include <GL/glew.h>

static const char *vs_src =
    "#version 330 core\n"
    "layout(location = 0) in vec2 pos;\n"                    /* вершинный шейдер: где точка */
    "void main() { gl_Position = vec4(pos, 0.0, 1.0); }\n";
static const char *fs_src =
    "#version 330 core\n"
    "out vec4 color;\n"                                      /* фрагментный шейдер: какого цвета пиксель */
    "void main() { color = vec4(1.0, 0.8, 0.2, 1.0); }\n";

static GLuint shader(GLenum type, const char *src)
{
    GLuint s = glCreateShader(type);
    glShaderSource(s, 1, &src, NULL); glCompileShader(s);
    GLint ok; glGetShaderiv(s, GL_COMPILE_STATUS, &ok);
    if (!ok) { char log[512]; glGetShaderInfoLog(s, sizeof log, NULL, log); printf("шейдер: %s\n", log); }
    return s;
}

int main(void)
{
    SDL_Init(SDL_INIT_VIDEO);
    SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3); SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 3);
    SDL_Window *w = SDL_CreateWindow("gl", 0, 0, 640, 480, SDL_WINDOW_OPENGL);
    SDL_GLContext ctx = SDL_GL_CreateContext(w);
    if (!ctx) { printf("контекст OpenGL: %s\n", SDL_GetError()); return 1; }
    glewInit();
    printf("OpenGL %s, %s\n", glGetString(GL_VERSION), glGetString(GL_RENDERER));
    GLuint prog = glCreateProgram();
    glAttachShader(prog, shader(GL_VERTEX_SHADER, vs_src)); glAttachShader(prog, shader(GL_FRAGMENT_SHADER, fs_src));
    glLinkProgram(prog);
    float tri[] = { -0.5f, -0.5f, 0.5f, -0.5f, 0.0f, 0.5f };  /* три вершины в координатах −1…1 */
    GLuint vao, vbo;
    glGenVertexArrays(1, &vao); glBindVertexArray(vao);
    glGenBuffers(1, &vbo); glBindBuffer(GL_ARRAY_BUFFER, vbo);
    glBufferData(GL_ARRAY_BUFFER, sizeof tri, tri, GL_STATIC_DRAW);   /* вершины — в память видеокарты */
    glEnableVertexAttribArray(0); glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 0, 0);
    glClearColor(0.1f, 0.1f, 0.2f, 1); glClear(GL_COLOR_BUFFER_BIT);
    glUseProgram(prog); glDrawArrays(GL_TRIANGLES, 0, 3);      /* конвейер: вершины → растеризация → фрагменты */
    SDL_GL_SwapWindow(w);
    unsigned char *px = malloc(640 * 480 * 3);                 /* прочитать кадр обратно из видеопамяти */
    glReadPixels(0, 0, 640, 480, GL_RGB, GL_UNSIGNED_BYTE, px);
    int yellow = 0;
    for (int i = 0; i < 640 * 480; i++) if (px[3 * i] == 255 && px[3 * i + 1] == 204 && px[3 * i + 2] == 51) yellow++;
    printf("треугольник: %d пикселей цвета (255,204,51) из %d (ожидается около %d)\n", yellow, 640 * 480, 640 * 480 / 8);
    SDL_Surface *s = SDL_CreateRGBSurfaceFrom(px, 640, 480, 24, 640 * 3, 0x0000ff, 0x00ff00, 0xff0000, 0);
    SDL_SaveBMP(s, "/tmp/tri.bmp"); SDL_FreeSurface(s); free(px);
    SDL_GL_DeleteContext(ctx); SDL_DestroyWindow(w); SDL_Quit();
    return 0;
}

Next: section 150 "ESP32 as a platform: chip, module, memory, pins, boot".

Open in the interactive handbook