Embedded HandbookПриложения
Все разделы › XVII. Системное программирование на C
· уровень средний · 12 мин чтения
Открыть в интерактивном справочнике

149. Графика и игры на C: SDL2, игровой цикл, OpenGL

Игра — та же программа реального времени, что и прошивка с дисплеем (раздел 22а): опросить ввод, пересчитать состояние, нарисовать кадр, и так шестьдесят раз в секунду. Отличие в масштабе: миллион пикселей вместо тысячи и видеокарта, которая рисует их за программу. Раздел — про два слоя: SDL2, который даёт окно, ввод, звук и простое рисование на любой системе, и OpenGL, который отдаёт рисование видеокарте через шейдеры. Обе программы прогнаны в stm-sys:1 без экрана: SDL — на драйвере dummy, OpenGL — через offscreen и программный растеризатор Mesa; кадры сохранены в BMP и проверены по пикселям (_verify/sys149/).

Как это называется в документации

Оригинал Перевод
game loop игровой цикл: ввод → обновление → кадр
fixed timestep фиксированный шаг обновления
frame rate (FPS), frame time частота и время кадра
renderer, surface, texture отрисовщик, поверхность, текстура
event событие (клавиша, мышь, закрытие окна)
GPU, graphics pipeline видеопроцессор, графический конвейер
vertex, fragment вершина, фрагмент (будущий пиксель)
shader (GLSL) шейдер: программа для видеокарты
VAO, VBO описание и буфер вершин
rasterization растеризация: из треугольников в пиксели
software rasterizer (llvmpipe) программная растеризация на процессоре
double buffering, vsync двойная буферизация, синхронизация с развёрткой

149.1 Что такое игра с точки зрения программы

    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);
    }

Три шага в цикле — и главный вопрос: от чего зависит движение. Если двигать мяч на постоянный шаг каждый кадр, скорость игры зависит от частоты кадров: на быстрой машине мяч летит быстрее. Если на время кадра — при долгом кадре мяч прыгает сквозь стенку. Решение — фиксированный шаг: физика считается порциями по 10 мс сколько раз нужно, чтобы догнать реальное время (lag), а рисуется столько кадров, сколько успевается:

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

99 обновлений за секунду при 921 кадре: физика идёт своим темпом независимо от рисования, и на любой машине мяч за секунду пролетит те же 120 пикселей. update получает dt только от шага, а не от кадра:

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;
}

У фиксированного шага две ловушки. Первая — если update дольше шага (слабая машина, остановка в отладчике), lag растёт быстрее, чем цикл его разбирает: догонялка съедает весь процессор и не догоняет никогда. Поэтому lag ограничивают сверху — if (lag > 250) lag = 250; — и после паузы игра просто пропускает время. Вторая — при 921 кадре на 99 шагов физики девять-десять кадров подряд рисуют одно и то же положение, и на быстром экране движение подёргивается. Рисуют промежуточное: x_prev + (x − x_prev) · lag / STEP_MS, где x_prev — положение до последнего шага.

Тот же принцип — в прошивке: таймер даёт тик, обработка идёт по тику, а экран обновляется как успеет (раздел 22а).

Игровой цикл: ввод, накопленное время, физика фиксированным шагом сколько нужно, кадр как успели


149.2 SDL2: окно, ввод, рисование

SDL прячет операционную систему: одно API для Windows, Linux, macOS, Android и консолей. SDL_Init, SDL_CreateWindow, SDL_CreateRenderer — и есть где рисовать; события — SDL_PollEvent в цикле; SDL_GetTicks — миллисекунды с запуска; SDL_Delay — уступить процессор.

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);
}

Кадр рисуется в скрытом буфере и показывается целиком (SDL_RenderPresent) — двойная буферизация, без мерцания. Без экрана SDL умеет драйвер dummy (в контейнере доступны x11 wayland KMSDRM offscreen dummy evdev): всё работает, кадр можно прочитать SDL_RenderReadPixels и сохранить SDL_SaveBMP. В кадре ровно 256 пикселей цвета мяча — 16 × 16, ни одним больше. Так игру проверяют автоматически, без человека у экрана.

Дополнения: SDL_image — PNG и JPEG, SDL_ttf — шрифты, SDL_mixer — звук, SDL_net — сокеты. На SDL стоят порты классических игр и эмуляторы; ей же пользуются как слоем под OpenGL и Vulkan.


149.3 OpenGL: конвейер и шейдеры

Видеокарта не рисует «мяч» — она рисует треугольники. Программа отдаёт вершины, видеокарта прогоняет их через конвейер: вершинный шейдер решает, где точка на экране; растеризатор превращает треугольник в фрагменты; фрагментный шейдер решает цвет каждого. Шейдеры — программы на GLSL, компилируются во время работы:

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";

Вершины кладут в буфер видеокарты (VBO) и описывают их раскладку (VAO); одна команда рисует:

    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);      /* конвейер: вершины → растеризация → фрагменты */

Контекст OpenGL создаёт SDL (SDL_GL_CreateContext), функции OpenGL выше версии 1.1 подгружает GLEW. В контейнере нет видеокарты, но есть Mesa с программным растеризатором llvmpipe, и драйвер SDL offscreen даёт ему поверхность:

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

Кадр прочитан обратно glReadPixels: треугольник с вершинами (−0,5; −0,5), (0,5; −0,5), (0; 0,5) занимает ровно восьмую часть экрана 640 × 480 — 38 400 пикселей из 307 200, и растеризатор нарисовал ровно столько. Цвет vec4(1.0, 0.8, 0.2) стал (255, 204, 51): 0,8 × 255 = 204.

Конвейер OpenGL из опыта: три вершины в координатах от минус единицы до единицы, вершинный шейдер с gl_Position, растеризатор, фрагментный шейдер с цветом vec4 один, ноль восемь, ноль два и кадр 640 на 480; треугольник с основанием и высотой 1 в поле 2 на 2 занимает восьмую часть — 38 400 пикселей из 307 200, цвет в кадре 255, 204, 51

Фиксированный конвейер старого OpenGL (glBegin/glVertex) в современных профилях удалён: без шейдеров ничего не рисуется. Сегодня рядом с OpenGL — Vulkan (ниже уровнем, больше кода) и Metal (Apple); для игры на C проще взять готовый движок или SDL с его отрисовщиком.


149.4 Игра целиком

Минимальный набор: цикл с фиксированным шагом, состояние игры в одной структуре, разбор ввода в события, отрисовка из состояния, звук через SDL_mixer, таблица рекордов в файле. Ошибки, повторяющиеся из проекта в проект: физика привязана к кадру; рисование внутри update; всё в main на две тысячи строк; SDL_Delay(16) вместо измерения времени (на медленной машине игра замедляется, на быстрой — жжёт процессор). Паттерны с именами: игровой цикл, компонент, машина состояний (меню → игра → пауза), объектный пул (раздел 72 — пул узлов) — те же, что в прошивке, и знакомые по разделу 146 таблицы функций.


149.5 Грабли

Симптом Причина
игра быстрее на быстрой машине шаг физики привязан к кадру — фиксированный шаг
мяч пролетает сквозь стенку долгий кадр и один большой dt — несколько шагов по 10 мс
после паузы игра «замирает» и грузит процессор lag без верхней границы: догонялка не догоняет
движение подёргивается при частых кадрах рисуется последнее положение физики, а не промежуточное по lag / STEP_MS
100 % процессора без дела нет SDL_Delay или vsync
окно «не отвечает» SDL_PollEvent не вызывается каждый кадр
чёрный экран в OpenGL 3.3+ нет шейдеров: фиксированный конвейер удалён
glGenVertexArrays — «неопределённая ссылка» или падение функции выше 1.1 без загрузчика (GLEW)
SDL_GL_CreateContext: Invalid window драйвер dummy не умеет OpenGL — offscreen
цвет не тот vec4(1.0, 0.8, 0.2) → (255, 204, 51); байты и доли путают
в контейнере нет видеокарты Mesa llvmpipe: медленно, но точно — годится для проверок

149.6 Итоги

Часть XVII закрыта.

Полные исходники

loop_sdl.c — игровой цикл на SDL2 с фиксированным шагом обновления.

/* Раздел 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 — минимальная программа OpenGL: окно, шейдеры, треугольник.

/* Раздел 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;
}

Дальше: раздел 150 «ESP32 как платформа: чип, модуль, память, выводы, загрузка».

Открыть в интерактивном справочнике