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 (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 Итоги
- Игровой цикл: ввод → обновление фиксированным шагом → кадр; 99 обновлений и 921 кадр за секунду, движение не зависит от кадров.
- SDL2: окно, события, отрисовщик,
dummyбез экрана; кадр проверен по пикселям (256 = 16 × 16). - OpenGL: вершины → вершинный шейдер → растеризация → фрагментный шейдер; VAO/VBO; треугольник — ровно 38 400 пикселей из 307 200.
- Без видеокарты — Mesa
llvmpipeчерез SDLoffscreen.
Часть 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 как платформа: чип, модуль, память, выводы, загрузка».