Движок с нуля
Часть 2 · Первый треугольник · глава 2.2

Окно и главный цикл

Откроешь окно GLFW и напишешь главный цикл, который держит окно на экране, пока окно не закроют крестиком.

Откроем наш проект в Visual Studio. GLFW уже собрана и подключена, но app пользуется библиотекой только ради одной строки в консоли и сразу завершается, как обычная консольная утилита. Движку нужна программа другого рода: окно, которое остаётся на экране, пока пользователь не закроет окно крестиком, и в котором потом видеокарта будет рисовать картинку.

Запустить GLFW

Почти любую функцию GLFW можно вызывать только после glfwInit. Функция glfwInit готовит GLFW к работе: загружает библиотеки Windows и узнаёт, какие мониторы подключены. Пара к glfwInit — функция glfwTerminate: она закрывает окна, которые программа не закрыла сама, и отдаёт Windows всё, что glfwInit взяла. Поэтому glfwTerminate вызывают перед тем, как программа завершится.

GLFW сообщает об ошибке не исключением и не текстом в консоли: GLFW вызывает функцию, которую ты передал заранее. Такую функцию называют функцией обратного вызова (по-английски callback): ты отдаёшь функцию библиотеке, а библиотека сама вызывает функцию, когда нужно. Функцию для ошибок программа передаёт в GLFW через glfwSetErrorCallback («задать функцию для ошибок»).

Твой ход

Открой main.cpp в корне проекта, D:\dev\MyEngine\main.cpp, и замени всё содержимое файла на код из карточки рядом. Строки, которая печатала версию GLFW, в новом коде нет, а отмеченные строки новые.

Объяснение

Первая новая строка, glfwSetErrorCallback, передаёт GLFW функцию для ошибок. Сама функция записана прямо в скобках, без имени. Такая функция без имени называется лямбдой. GLFW передаст в лямбду два значения: номер ошибки code и описание ошибки description.

Лямбда печатает строку вида GLFW error 65537: … через std::cerr, а не через std::cout. std::cerr — поток для ошибок: отдельный выход программы, куда принято писать сообщения об ошибках.

Функцию для ошибок задают первой, ещё до glfwInit: тогда, если не получится сама glfwInit, функция для ошибок тоже напечатает причину. Если glfwInit вернула 0, программа выходит с кодом возврата 1, то есть сообщает о неудаче.

Запуск

Запусти программу без отладки (Ctrl+F5). Программа ничего не печатает, поэтому в консоли будет только строка Visual Studio о том, что программа завершилась с кодом 0:

Консоль app
D:\dev\MyEngine\out\build\vs2026\Debug\app.exe (process 14220) exited with code 0 (0x0).Press any key to close this window . . .

Код возврата 0 значит, что программа дошла до строки return 0. Значит, проверка if (!glfwInit()) не сработала: glfwInit вернула 1, и GLFW готова к работе.

main.cpp
#include <format>#include <iostream>#include <GLFW/glfw3.h>int main(){    glfwSetErrorCallback([](int code, const char* description) {        std::cerr << std::format("GLFW error {}: {}\n", code, description);    });    if (!glfwInit())    {        return 1;    }    glfwTerminate();    return 0;}

В новом коде две непривычные вещи: функция без имени, записанная прямо в скобках, и библиотека, которая сама вызывает твой код. Схема ниже разбирает по шагам обе вещи: как обычная функция становится лямбдой и как GLFW находит лямбду, когда случается ошибка.

Как работает callback в C++ на примере glfwSetErrorCallback

Начнём с того, что ты уже умеешь писать. Обычная функция PrintError стоит над main, а строка внутри main отдаёт эту функцию в GLFW. Имя PrintError нужно здесь ровно один раз: чтобы сослаться на функцию в этой строке.
Раз имя нужно один раз, C++ разрешает обойтись без имени. Параметры и тело функции переезжают прямо в скобки вызова, имя исчезает, а на месте имени появляются квадратные скобки []. Получилась лямбда. Это та же самая функция с теми же параметрами и тем же телом, только записанная на месте.
Компилятор делает из лямбды настоящую функцию и сам придумывает функции имя. Когда программа запущена, машинный код лямбды лежит в памяти программы по своему адресу, как дом на улице. Адрес — обычное число, а число можно передать другой функции. Так функцию и отдают библиотеке.
В GLFW есть переменная _glfwErrorCallback, и сначала в этой переменной пусто. Строка с glfwSetErrorCallback записывает в переменную адрес лямбды: это стрелка 1. Сама лямбда при этом не выполняется, GLFW только запомнила адрес.
Позже программа вызывает glfwInit: это стрелка 2. Допустим, внутри что-то не получилось. Тогда GLFW заглядывает в свою переменную _glfwErrorCallback и смотрит, записан ли в ней адрес лямбды: это стрелка 3. Если там пусто, GLFW ничего не вызывает, а если адрес есть, он будет использован на следующем шаге.
Адрес в переменной есть, поэтому GLFW вызывает функцию по этому адресу. Лямбду вызывает последняя строка GLFW, _glfwErrorCallback(code, description);: строка берёт адрес из переменной и переходит по нему в лямбду. Это стрелка 4, момент, когда программа выходит из кода GLFW и попадает в функцию, которую написал ты. Строка передаёт лямбде два значения: номер ошибки попадает в параметр code, а описание ошибки попадает в параметр description.
Теперь выполняется твоя лямбда с параметрами, которые передала GLFW: code и description. Тело лямбды печатает, и строка об ошибке попадает в консоль: это стрелка 5. Лямбда работает как любая функция: когда функция заканчивается, программа продолжает работу с той строки, которая функцию вызвала. Лямбду вызвала строка в GLFW, поэтому программа возвращается в GLFW: это стрелка 6.
GLFW доделывает свою работу, и glfwInit возвращает 0 в твою программу: это стрелка 7. Проверка в main срабатывает, и программа выходит с кодом 1.
Теперь на схеме нарисован весь маршрут. Посмотри на строки main: ни одна не вызывает лямбду. Программа только передала адрес и пошла дальше. Единственный вызов лямбды стоит в коде GLFW и выполняется, только когда случилась ошибка. Программа заранее не знает, будет ли ошибка и в какой функции GLFW, а GLFW узнаёт об ошибке первой. Так работает любая функция обратного вызова: что сделать, пишешь ты, а вызывает этот код библиотека, когда наступает нужное событие.

Почему лямбду можно отдать в glfwSetErrorCallback

glfwSetErrorCallback ждёт не лямбду, а указатель на функцию. Тип указателя GLFWerrorfun записан в заголовке GLFW, D:\dev\MyEngine\out\build\vs2026\_deps\glfw-src\include\GLFW\glfw3.h: функция, которая принимает int и const char* и ничего не возвращает.

glfw-src\include\GLFW\glfw3.hстрока 1595
typedef void (* GLFWerrorfun)(int error_code, const char* description);

GLFW написана на C, а в C нет лямбд. Но C++ умеет превращать лямбду в обычный указатель на функцию, если квадратные скобки у лямбды пустые: такой лямбде не нужно ничего, кроме своих параметров, и её код можно вызвать как обычную функцию. Если в скобках что-нибудь написать, например [&], лямбда унесёт с собой переменные из main, превращение в указатель станет невозможным, и компилятор остановится с ошибкой C2664: не может превратить лямбду в GLFWerrorfun.

Открыть окно

GLFW работает, и теперь программа откроет окно. Окно создаёт функция glfwCreateWindow, а убирает функция glfwDestroyWindow.

Твой ход

Допиши в main.cpp строки, отмеченные в карточке рядом: создание окна с проверкой после glfwInit и glfwDestroyWindow перед glfwTerminate в конце.

Объяснение

glfwCreateWindow принимает пять значений: ширину и высоту, заголовок, монитор и окно для общих данных.

Функция возвращает GLFWwindow* — указатель на окно. Каждая функция GLFW, которой нужно окно, получает этот указатель первым аргументом: по указателю функция находит данные нужного окна. Окон может быть несколько, и указатель говорит, с каким из них работать.

Если окно создать не удалось, glfwCreateWindow возвращает nullptr, а лямбда печатает причину. Тогда программа выключает GLFW через glfwTerminate и выходит с кодом 1. В проверке if (!glfwInit()) вызова glfwTerminate нет, потому что неудачная glfwInit сама убирает всё, что успела создать. А здесь glfwInit уже сработала, и выключить GLFW должна программа.

glfwDestroyWindow уничтожает окно, то есть просит Windows закрыть окно навсегда, и освобождает всё, что GLFW создала для этого окна.

Запуск

Запусти программу без отладки (Ctrl+F5). Окно мелькнёт на долю секунды, а может, ты окно даже не заметишь, и в консоли снова будет строка exited with code 0.

main.cpp
#include <format>#include <iostream>#include <GLFW/glfw3.h>int main(){    glfwSetErrorCallback([](int code, const char* description) {        std::cerr << std::format("GLFW error {}: {}\n", code, description);    });    if (!glfwInit())    {        return 1;    }    GLFWwindow* window = glfwCreateWindow(1280, 720, "Engine", nullptr, nullptr);    if (!window)    {        glfwTerminate();        return 1;    }    glfwDestroyWindow(window);    glfwTerminate();    return 0;}
Если не получилось1
GLFW error 65542: WGL: The driver does not appear to support OpenGL Строка в консоли, а ниже exited with code 1.
Почему
По умолчанию glfwCreateWindow не только создаёт окно, но и сразу готовит окно к рисованию через OpenGL. Для этого GLFW обращается к драйверу видеокарты: OpenGL работает внутри драйвера. Если драйвер OpenGL не поддерживает, GLFW не создаёт окно. Такое может случиться в виртуальной машине, при входе на компьютер через удалённый рабочий стол или когда стоит простой драйвер, который Windows поставила сама. Код работает верно: glfwCreateWindow вернула nullptr, лямбда напечатала причину, а программа вышла с кодом 1.
Что сделать

Запусти программу прямо на компьютере, а не через удалённый рабочий стол. Если ошибка осталась, установи драйвер видеокарты с сайта производителя: NVIDIA, AMD или Intel.

Окно мелькнуло и сразу пропало. Схема ниже показывает почему: в каком порядке программа выполняет вызовы GLFW и сколько времени окно существует между этими вызовами.

Почему окно GLFW сразу закрывается

Вызовы стоят в том порядке, в каком программа выполняет эти вызовы. У каждого вызова нарисован кадр экрана: кадр показывает, что видно на экране сразу после этого вызова. Пройдём вызовы по одному.
glfwInit включает GLFW. С этого вызова GLFW работает, и можно вызывать почти любую другую функцию GLFW. На экране пока ничего нет.
glfwCreateWindow создаёт окно, и окно появляется на экране.
Следующий вызов — уже glfwDestroyWindow: в коде между этими двумя вызовами нет ни одной строки, которая задержала бы программу. Окно исчезает с экрана через долю секунды после появления: программа сделала ровно то, что написано.
glfwTerminate выключает GLFW. Время жизни окна лежит целиком внутри времени работы GLFW, поэтому при выходе порядок обратный запуску: сначала программа уничтожает окно, потом выключает GLFW. Чтобы окно осталось на экране, программе нужно задержаться между созданием окна и уничтожением окна.

Главный цикл

Чтобы окно оставалось на экране, программа не должна доходить до glfwDestroyWindow, пока окно не закрыли. Для этого в движке есть главный цикл: цикл while, который крутится между созданием окна и glfwDestroyWindow.

Твой ход

Допиши цикл между проверкой if (!window) и строкой glfwDestroyWindow. Новые строки в карточке рядом отмечены.

Объяснение

Цикл while снова и снова выполняет строки в фигурных скобках, пока условие в круглых скобках верно. Здесь в фигурных скобках одна строка, glfwPollEvents, а в условии стоит вызов glfwWindowShouldClose.

glfwPollEvents на каждой итерации забирает у Windows всё, что случилось с окном: движения мыши, нажатия клавиш, щелчок по крестику. Если не забирать, Windows решит, что программа не отвечает.

glfwWindowShouldClose(window) окно не закрывает, а только отвечает на вопрос «просили ли закрыть это окно?». Ответ «да» появляется, когда glfwPollEvents увидела щелчок по крестику. Перед вызовом стоит !, который переворачивает ответ. Пока крестик не нажали, ответ «нет», с ! условие верно, и цикл крутится дальше. После щелчка по крестику ответ «да», с ! условие неверно, цикл заканчивается, и программа идёт к glfwDestroyWindow.

Запуск

Запусти программу без отладки (Ctrl+F5). Теперь окно остаётся на экране: окно можно перетащить за заголовок, растянуть за край и свернуть. Закрой окно крестиком, и в консоли появится строка exited with code 0.

Внутри окна пока пусто: программа ещё ничего не рисует. Зато окно двигается, меняет размер и закрывается крестиком, и всё это делают две строки цикла: glfwPollEvents и условие с glfwWindowShouldClose.

main.cppстроки 18–33коммит 197e987
    GLFWwindow* window = glfwCreateWindow(1280, 720, "Engine", nullptr, nullptr);    if (!window)    {        glfwTerminate();        return 1;    }    while (!glfwWindowShouldClose(window))    {        glfwPollEvents();    }    glfwDestroyWindow(window);    glfwTerminate();    return 0;}

Схема ниже показывает, как две строки цикла управляют окном: откуда берутся движения мыши и щелчок по крестику и как щелчок заканчивает цикл.

Как окно получает события: зачем нужен glfwPollEvents

На схеме три части: окно Engine на экране, Windows и твоя программа. Windows следит за мышью и клавиатурой и записывает всё, что происходит с окном, в очередь твоей программы. В программе нарисован главный цикл с двумя шагами, а точка показывает, какую строку цикла программа сейчас выполняет. Когда с окном что-то происходит, Windows не прерывает программу и не вызывает в ней никакую функцию. Windows только дописывает запись о событии в конец очереди. Запись лежит в очереди, пока программа сама не заглянет в очередь и не заберёт запись.
Ты двигаешь мышь над окном Engine. Windows замечает каждое движение и кладёт в очередь событий событие: запись «мышь сдвинулась в такую-то точку». Так же появляются события от нажатой клавиши, от перетаскивания окна за заголовок и от кнопок в заголовке. События копятся в очереди, пока программа не заберёт события.
Каждая итерация цикла вызывает glfwPollEvents («опросить события»). Функция забирает из очереди событий всё, что накопилось, и разбирает каждое событие. Положение мыши GLFW запоминает в самом окне GLFW, чтобы программа потом могла спросить, где мышь. Перетаскивание и изменение размера GLFW отдаёт обратно Windows, и Windows двигает окно. Поэтому окно двигается только тогда, когда программа вызывает glfwPollEvents. Если программа перестанет забирать события, очередь будет расти, окно перестанет двигаться, и через несколько секунд Windows допишет в заголовок окна «(Не отвечает)».
Ты нажимаешь крестик в заголовке. Окно от щелчка не исчезает: Windows только кладёт в очередь событие «закрыть». На следующей итерации glfwPollEvents забирает это событие, и GLFW ставит окну флаг «закрыть» со значением «да». Флаг — поле внутри окна GLFW, переменная со значением «да» или «нет». Закрывать ли окно на самом деле, решает программа: так текстовый редактор успевает спросить «Сохранить изменения?», прежде чем закрыться.
Точка доходит до условия цикла. glfwWindowShouldClose («должно ли окно закрыться») читает флаг «закрыть» и возвращает 1, если флаг стоит, и 0, если нет. Пока флаг «нет», цикл начинает новую итерацию. Когда флаг «да», цикл заканчивается, и программа идёт к строкам после цикла.
После цикла glfwDestroyWindow уничтожает окно: Windows закрывает окно навсегда, и окна больше нет. Затем glfwTerminate выключает GLFW, строка return 0 отдаёт код возврата 0, и Visual Studio пишет в консоли exited with code 0.

На схеме цикл замедлен, чтобы было видно каждый шаг. Настоящий цикл ничего не ждёт: как только glfwPollEvents разобрала очередь, цикл сразу начинает новую итерацию, даже если событий не было. Поэтому пустое окно целиком занимает один логический процессор. Проверить это можно в Visual Studio. Запусти программу с отладчиком, зелёной кнопкой или клавишей F5, и не закрывай окно Engine: цикл должен работать. Справа от кода откроется окно Diagnostic Tools. В окне найди нижний график с подписью CPU (% of all processors), то есть «процессор, процентов от всех процессоров». График показывает, какую долю всей мощности процессора занимает программа. Пока окно Engine открыто, линия графика стоит примерно на одном уровне: около 12%, если логических процессоров 8, и около 6%, если 16.

Почему на графике около 12% или 6%, а не 100%

График показывает долю не от одного ядра, а от всего процессора. Windows делит процессор на логические процессоры. Наш цикл работает в одном потоке, поэтому занимает ровно один логический процессор, и занимает полностью: цикл ни разу не отдыхает.

Один занятый из 8 логических процессоров даёт на графике 1 из 8, то есть около 12%, а из 16 даёт около 6%. Пока цикл крутится, график стоит на этом уровне.

Если открыть диспетчер задач Windows и переключить график ЦП с общего на логические процессоры (правая кнопка мыши по графику → «Изменить график» → «Логические процессоры»), график разобьётся на отдельные окошки, по одному на каждый логический процессор. Ни один график при этом не упрётся в 100%: Windows каждые доли секунды переносит поток цикла с одного логического процессора на другой, и нагрузка перебегает по окошкам. Поэтому в каждом окошке видны невысокие всплески, а общая загрузка процессора на графике ЦП остаётся около одного логического процессора из всех, например около 8% при двенадцати.

Приём: glfwWaitEvents для программ, которые не рисуют каждый кадр

У GLFW есть вторая функция для очереди событий — glfwWaitEvents («ждать события»). Если очередь пуста, glfwWaitEvents не возвращается сразу, а усыпляет программу, пока Windows не положит в очередь новое событие. Процессор в это время свободен.

Попробуй заменить в цикле glfwPollEvents() на glfwWaitEvents() и запусти программу с отладчиком (F5): график в окне Diagnostic Tools опустится почти до нуля, а пока ты двигаешь мышь над окном, график будет подниматься. Так работают редакторы и обычные программы с окнами: делать что-то нужно, только когда пользователь что-то сделал.

Игре и движку glfwWaitEvents не подходит: картинка в игре меняется и без действий пользователя, поэтому цикл должен крутиться без остановки. Верни в цикл glfwPollEvents().

Итог

Программа app запускает GLFW, открывает окно 1280 на 720 и крутит главный цикл: каждая итерация забирает события окна, а после крестика выходит из цикла, убирает окно и выключает GLFW.

Твой ход · коммит

В окне Git ChangesИзменения Git дважды щёлкни каждый файл из списка ChangesИзменения и прочитай изменения. Если всё устраивает, закоммить и отправь на GitHub с сообщением Open a GLFW window and run the main loop.

Так выглядит main.cpp после главы. CMakeLists.txt в этой главе не менялся.

main.cpp
main.cppкоммит 197e987
#include <format>#include <iostream>#include <GLFW/glfw3.h>int main(){    glfwSetErrorCallback([](int code, const char* description) {        std::cerr << std::format("GLFW error {}: {}\n", code, description);    });    if (!glfwInit())    {        return 1;    }    GLFWwindow* window = glfwCreateWindow(1280, 720, "Engine", nullptr, nullptr);    if (!window)    {        glfwTerminate();        return 1;    }    while (!glfwWindowShouldClose(window))    {        glfwPollEvents();    }    glfwDestroyWindow(window);    glfwTerminate();    return 0;}