урок 11
- Немедленно закачай его обратно! Скоро из-за таких, как ты, в интернете совсем файлов не останется!
Изображение отрисовывается с фиксированными на момент написания кода высотой и шириной. Эти значения "зашиты" в константах времени компиляции WIDTH и HEIGHT - то есть, ширина и высота экрана являются по сути просто цифрами в тексте программы. Значения ширины-высоты (размеры экрана) намертво запечатлелись из констант в момент компиляции. При увеличении размера окна эмулятора терминала изображение остаётся в левом верхнем уголке консоли, при уменьшении окна - совсем плохо дело, изображение рассыпается/выводится неполностью. Для возможности отображения масштабированного изображения программа сама должна знать две вещи: размеры терминала (не в момент компиляции, а всегда - в рантайме), и момент изменения размеров.
Решение вопроса получения значений ширины и высоты окна можно поручить функции recalculate_screen_size()
. Бывшие константы WIDTH и HEIGHT при этом должны стать переменными времени исполнения width и height(Возможность изменения размера экрана диктует необходимость хранения этого размера в переменных)
Язык C позволяет создавать статические массивы (массивы, которыми мы пользовались ранее, создаваемые компилятором на стеке и оттого не требующие ручного контроля за их уничтожением, "освобождением памяти") только известного на момент компиляции размера. Значения WIDTH и HEIGHT известны на момент компиляции, препроцессор ещё до этапа компиляции объектных файлов подставил вместо них соответствующие числа - и поэтому компилятор мог вычислить значение выражения [(WIDTH + 1) * HEIGHT]
, которое для него (после работы препроцессора) выглядит как (80 + 1) * 24
, соответственно компилятор понимал, что встретив строку char screen[(WIDTH + 1) * HEIGHT];
, т.е char screen[1944]
- он должен выделить в памяти непрерывный кусок длинной 1944 байта, и сопоставить с адресом этого куска имя screen - а по этому адресу будут располагаться элементы типа char, в количестве 1944 штук.
Теперь же, после того, как константы сменились переменными, встретив строку char screen[(width + 1) * height];
компилятор не может выделить память, т.к. значение переменных - величина переменная, и имеет смысл только во время работы программы. В момент компиляции никакой программы ещё нет, эти переменные вообще могут быть определены в другой единице трансляции. И что ещё хуже, значение переменных может измениться: допустим, компилятор выделил где-то память, значение переменных увеличилось, соответственно увеличился и размер массива - но в том месте, где компилятор выделил память, возможно (да наверняка!) нет дополнительного места для появившегося "хвоста" массива.
Массивы, меняющие размер во время работы программы называются "динамическими массивами" и в языке C работа с динамическими массивами (в более общем случае, с динамической памятью) обычно реализуется через функции malloc()
, calloc()
, realloc()
и free()
. Функции выделения памяти возвращают указатель на память требуемого размера, (кроме free()
, которая удаляет ранее выделенную память, выделенную память всегда нужно удалять, когда она перестала быть нужна. Иначе - "утечка памяти": приложение рано или поздно, но неминуемо, съест всю память в системе и системе придётся убить приложение).
#include <stdio.h> /* printf() */
#include <stdlib.h> /* malloc(), realloc() */
#include <sys/ioctl.h> /* struct winsize, TIOCGWINSZ */
#include <unistd.h> /* STDOUT_FILENO */
#include "graphics.h"
int width;
int height;
struct winsize window_info; /* struct to store terminal window info */
char *screen = NULL;
int get_width()
{
return width;
}
int get_height()
{
return height;
}
void recalculate_screen_size()
{
ioctl(STDOUT_FILENO, TIOCGWINSZ, &window_info);
width = window_info.ws_col;
height = window_info.ws_row;
/* every 'screen' line have extra char for linebreak */
screen = realloc(screen, ((width + 1) * height) * sizeof(char));
}
...
void init_graphics()
{
resize_screen();
init_terminal();
init_screen();
}
void finalize_graphics()
{
free(screen);
restore_terminal();
}

ioctl()
используется эту структуру для возвращаета информации о окне эмулятора терминала.
Системный вызов
ioctl()
(input/output control) - служит для взаимодействия с устройствами ввода-вывода. Так как устройств ввода-вывода невообразимо много, писать отдельный системный вызов для взаимодействия с каждым из них оказалось невозможно - иначе устройства, появившиеся после выхода ОС не могли бы поддерживаться в принципе, поэтому был реализован механизм взаимодействия через обобщённый вызов. Само устройство передаётся первым параметром (у нас это стандартный вход, для которого в заголовочном файле unistd.h описана константа STDOUT_FILENO. Вторым параметром передаётся константа вида взаимодействия с устройством - в нашем случае это TIOCGWINSZ, т.е. мы хотим запросить размер символьного устройства. Дальше в ioctl()
следуют уже специфичные для устройства и вида взаимодействия с ним параметры, в нашем случае это структура, через которую ioctl()
сможет вернуть запрошенные значения. Но, естественно, возможности ioctl()
намного шире - к примеру, если вы застали и помните CD-ROM приводы, то лоток открывается и закрывается именно через ioctl()
.
Значения ширины и высоты окна эмулятора терминала (в символах) можно получить из структуры winsize - они содержатся в её полях ws_col (columns) и ws_row, соответственно. И теперь, зная эти параметры, можно запросить у операционной системы память:
screen = realloc(screen, ((width + 1) * height) * sizeof(char));
. Вообще-то для получения памяти у операционной системы используется функция malloc()
(memory allocation), а для получения памяти, целиком затёртой нулями - чуть более медленная calloc()
(contiguous allocation, подразумевается что она должна использоваться для выделения памяти именно под массивы). Но функция realloc()
(reallocation), которая принимает указатель на ранее выделенную память и новый, требуемый размер памяти - может выделить новую память если ей передать указатель со значением NULL. Функция realloc()
может заменить и free()
- она освобождает память по указателю, если затребовать у неё 0 байт, но злоупотреблять этой возможностью не стоит, так как такие выкрутасы снижают читаемость кода. В нашем случае realloc()
используется для первоначального выделения памяти не лишь для унификации - чтобы не вызывать (и не писать) отдельную функцию для инициализации буфера изображения, при том что с этим прекрасно справляется resize_screen()
самостоятельно.
А дальше остаётся только освободить память, использованную для буфера изображения - при завершении графического использования терминала. Пока что корректное освобождение памяти, казалось бы, не играет роли - программа же всё равно завершается, и вся память заканчивающейся программы всё равно вернётся операционной системе? Но позже, вполне возможно, в игре появится, к примеру, режим двух экранов для двух игроков - и тогда по завершении мультиплеерного режима память ненужного более экрана останется висеть в системе мёртвым грузом, занимая ресурсы компьютера. Более того, освобождение памяти - это одна из самых главных привычек, которые стоит выработать. Утечка памяти - одна из самых больших проблем у программистов, работающих на языках с ручным контролем памяти, и не стоит позволять себе расслабляться в этом вопросе - потом окупится, если сразу привыкните делать корректно. screen - теперь не массив, а указатель на динамическую память - теперь наш буфер экрана содержится в динамической памяти. Указатель инициализируется значением NULL - в языке C это специальная константа, которая указывает, что указатель никуда не указывает. Синтаксис языка C позволяет работать с указателями как с массивами, в частности, на указателях работает адресная арифметика - как и с массивами (число в квадратных скобках позволяет обращаться к элементу массива), но тем не менее нужно помнить, что массивы и указатели в C - суть разные сущности.
В копилку хороших привычек при работе с динамической памятью нужно добавить ещё одну:
...
void finalize_graphics()
{
restore_terminal();
free(screen);
screen = NULL; //Всегда после освобождения памяти!
}
free()
освобождает память, но указатель это никак не затрагивает - он куда до освобождения указывал, туда и указывает - но указывает уже на деинициализированную память. Поэтому стоит озаботиться тем, чтобы он перестал указывать куда-то "пальцем в небо" - а значение NULL - оно специальное, и функции работы с памятью умеют понимать, что если указатель указывает на NULL - то память неинициализирована. Так, к примеру, следующий код вызовет ошибку, так как второй раз free()
попытается освободить память, которая (уже) не выделена:
free(screen);
free(screen);
free(screen);
screen = NULL;
free(screen);
Возвращаясь к отрисовке трёхмерной графики в терминале: существует сигнал SIGWINCH, который посылается программе каждый раз, когда размеры окна терминала изменяются. Установив диспозицию resize_screen сигнала SIGWINCH, мы заставим программу пересчитывать размеры буфера изображения каждый раз при изменении размеров окна, подстраивая буфер под него:
...
#include <signal.h>
...
void init_graphics()
{
resize_screen();
signal(SIGWINCH, resize_screen);
init_terminal();
init_screen();
}
...
Теперь изображение отрисовывается на всё окно, какой бы размер не приобретало окно терминала. Но: пропорции изображения зависят от пропорций экрана. При попытке вывести на экран квадрат он может принимать довольно неквадратные формы:
...
struct Triangle3d cube[] = {
{{ -0.8f, 0.8f, 0.0f}, { 0.8f, 0.8f, 0.0f}, { 0.8f, -0.8f, 0.0f}},
{{ -0.8f, 0.8f, 0.0f}, { 0.8f, -0.8f, 0.0f}, {-0.8f, -0.8f, 0.0f}},
};
int main()
{
init_graphics();
init_player_input(quit_game);
main_cycle = 1;
while(main_cycle)
{
init_screen();
draw_triangle3d(cube[0], '*');
draw_triangle3d(cube[1], '*');
render_scene();
}
finalize_graphics();
return 0;
}

Для сохранения корректных пропорций изображения - вне зависимости от соотношения сторон окна терминала - нужно сначала выбрать ориентир, к которому изображение будет подстраиваться. Легче всего за ориентир взять одну из размерностей экрана - вертикальную или горизонтальную. Так, при выборе вертикали - размер окна по вертикали принимается за единицу, а размер по горизонтали подсчитывается относительно вертикального размера. В таком случае (если вертикальный размер принят за единицу) увеличение окна по вертикали будет приводить к увеличению изображения, а увеличение по горизонтали - к тому, что больше изображения станет помещаться в кадр. Достигается это масштабированием по горизонтали: координаты по оси Y остаются прежними (умножаются на единицу), а координаты по X умножаются на ratio, ratio = width / height:
...
float ratio; /* screen ratio */
...
void resize_screen()
{
ioctl(STDOUT_FILENO, TIOCGWINSZ, &window_info);
width = window_info.ws_col;
height = window_info.ws_row;
ratio = (float)height / (float)width;
screen = realloc(screen, ((width + 1) * height) * sizeof(char));
}
...
void draw_triangle2d(struct Triangle2d triangle, char sprite)
{
int x = get_width();
int y = get_height();
float p1x = 1 + triangle.p1.x * ratio;
float p2x = 1 + triangle.p2.x * ratio;
float p3x = 1 + triangle.p3.x * ratio;
float p1y = 1 + triangle.p1.y;
float p2y = 1 + triangle.p2.y;
float p3y = 1 + triangle.p3.y;
p1x *= x / 2.0f;
p2x *= x / 2.0f;
p3x *= x / 2.0f;
p1y *= y / 2.0f;
p2y *= y / 2.0f;
p3y *= y / 2.0f;
float offset = -0.5f;
p1x = p1x + offset;
p2x = p2x + offset;
p3x = p3x + offset;
p1y = y - p1y + offset;
p2y = y - p2y + offset;
p3y = y - p3y + offset;
draw_line(p1x, p1y, p2x, p2y, sprite);
draw_line(p2x, p2y, p3x, p3y, sprite);
draw_line(p3x, p3y, p1x, p1y, sprite);
}
...

Учитывать соотношение высоты к ширине символа легко, это соотношение достаточно использовать совместно с вычисленным ratio экрана - использовать значение ratio, умноженное на соотношение сторон символа. Но где соотношение это взять? Нужно, естественно, спросить у терминала, только терминал может знать, как он отрисовывает символы. Мы уже использовали системный вызов ioctl()
для получения высоты и ширины экрана в символах, но структура winsize, кроме размера окна в символах, содержит ещё и поля размера окна в пикселях: тогда размер одного символа равен количеству пикселей в размерности, делённому на количество символов в размерности:
...
int width; /* in symbols */
int height; /* in symbols */
float pixel_ratio; /* square pixel aspect ratio of one symbol */
float ratio; /* screen ratio (pixel_ratio considered) */
...
void resize_screen()
{
ioctl(STDOUT_FILENO, TIOCGWINSZ, &window_info);
width = window_info.ws_col;
height = window_info.ws_row;
ratio = pixel_ratio * (float) height/ (float)width;
screen = realloc(screen, ((width + 1) * height) * sizeof(char));
}
void init_pixel_ratio()
{
ioctl(STDOUT_FILENO, TIOCGWINSZ, &window_info);
float x = window_info.ws_xpixel;
float y = window_info.ws_ypixel;
resize_screen(); /* need width and height already initialized */
pixel_ratio = ( y / height) / (x / width);
resize_screen(); /* and now ratio should be calculated */
}
void init_graphics()
{
...
init_terminal();
init_pixel_ratio();
init_screen();
}
...
ioctl()
- актуально. Нужно ещё и как-то контролировать, заполнил терминал значения полей, или нет? Создатели терминалов могут заполнить поля нулями, вместо актуальных значений, а могут - вообще не трогать. Поэтому по наличию старых, до вызова ioctl()
значений либо нулей можно судить, вернулось значение истинное или нет. И для получения размера экрана тогда можно попытаться использовать ещё один, также уже ранее использовавшийся (и тоже, к сожалению, не всегда поддерживаемый) инструмент - управляющие escape-последовательности. esc[14;0t
- последовательность, в ответ на которую терминал отвечает (устанавливает на своём выходе, т.е. на нашем стандартном вводе) значением ширины и высоты в пикселях. Инициализация соотношения сторон пикселя (символа) выполняется один раз, при инициализации графики - в расчёте на то, что пользователь не будет менять шрифты в процессе работы программы. Выполнять пересчёт сторон символа не следует, так как функция init_pixel_ratio()
общается с терминалом - а он, как физическое устройство ввода-вывода мееедленный - особенно когда мы общаемся с ним управляющими последовательностями.
Ну а если и на управляющую последовательность терминал не ответит - можно попытаться угадать соотношение размеров символа, использовав значение по умолчанию:
...
#include <termios.h> /* termios */
...
void resize_screen()
{
...
ratio = pixel_ratio * (float)height / (float)width;
...
}
void init_pixel_ratio()
{
const uint x_magic = 31337;
const uint y_magic = -31337;
window_info.ws_xpixel = x_magic;
window_info.ws_ypixel = y_magic;
ioctl(STDOUT_FILENO, TIOCGWINSZ, &window_info);
uint x = window_info.ws_xpixel;
uint y = window_info.ws_ypixel;
/* if ioctl fails to get terminal pixel size */
if (x < 1 || y < 1 || (x == x_magic && y == y_magic))
{
/* To been able to read terminal responce, need
to set terminal to unblocking (not CANON) mode: */
/* terminal I/O settings structure */
struct termios t_orig, t_manip;
/* get current settings */
tcgetattr(STDIN_FILENO, &t_orig);
t_manip = t_orig;
t_manip.c_lflag &= ~ICANON;
/* set to unblocking mode */
tcsetattr(STDIN_FILENO, TCSANOW, &t_manip);
/* ask terminal for pixel size */
printf("\033[14;0t");
/* wait for terminal responce */
usleep(1);
/* read terminal responce (if available) */
x = 0;
y = 0;
scanf("\033[4;%d;%dt", &x, &y);
/* restore terminal to previous settings */
tcsetattr(STDIN_FILENO, TCSANOW, &t_orig);
}
if (x >= 1 && y >= 1) /* terminal size in pixels success */
{
/* for pixel_ratio W&H already should be initialized */
resize_screen();
pixel_ratio = ((float)y / height) / ((float)x / width);
}
else /* still unable to get terminal pixel size */
{
pixel_ratio = 2.0f; /* defaul value */
fprintf(stderr, "ERROR: Graphic Initialization error. ");
fprintf(stderr, "Your terminal is not supported. ");
fprintf(stderr, "Symbol aspect ratio quessed as %f.\n", pixel_ratio);
}
resize_screen(); /* and now need to calculate screen ratio */
}
...

Функция init_pixel_ratio()
состоит из трёх смысловых блоков - трёх попыток получить соотношения сторон экрана в пикселях. Сначала уже рассмотренный выше (и посему знакомый) системный вызов ioctl - перед вызовом структура заполняется "магическими цифрами", чтобы после вызова проверить, записал терминал в поля ответ или оставил их нетронутыми. С учётом того, что терминал мог и просто нули записать (плохой, плохой терминал!) возможно, прийдётся спросить у терминала ответ через управляющие последовательности. Чтение ответа терминала - довольно нетривиальная задача, так как терминал, во-первых, может и совсем не ответить, проигнорировав escape-последовательность, и во-вторых - терминал отправляет свой ответ на свой ввод (как если бы кто-то напечатал разрешение на терминале), для нас это стандартный ввод. Но - этот кто-то не жмёт "Enter", то есть ответ из терминала в нашу программу не уходит. Ответ также начинается с escape-последовательности, и придерживается подобного запросу формата: esc[4;'width';'height't
. Но при этом обычно терминал работает в "каноническом" режиме - текст, те символы, что набираются на клавиатуре, просто появляются в строке терминала - программе они до нажатия "Enter" не отправляются. Сделано это для того, чтобы можно было подкорректировать строку - стереть символы ошибочные, допечатать вместо нужные. Пользуясь возможностями канонического режима, можно реализовать механизм перемещения курсор влево-вправо, и даже перелистывания прошлых строк, стрелками вверх-вниз, хоть мы этой возможностью пользоваться не будем.
Но чтобы программа сразу получала висящие в строке символы, как только она их запросит - не дожидаясь от пользователя нажатия "return", нужно отключить в терминале канонический режим работы. Для получения и установки режимов работы терминала служат функции tcgetattr()
и tcsetattr()
. Они принимают два параметра - тот вход, с которым будем работать (у нас терминал сейчас на стандартном входе) и структуру, в которую нужно записать, или из которой нужно установить режим работы. Соответственно, сначала считываем режим работы, в нём (с помощью битовых операций "~" и "&") устанавливаем в 0 бит ICANNIN поля c_lflag, и устанавливаем получившийся режим работы в терминал - какой и был, только теперь неканонический. Далее можно отправлять в терминал управляющую escape-последовательность esc[14;0t
, котора запрашивает у терминала его физическое разрешение в пикселях. Далее - неочевидный момент: нужно подождать. Терминал - это отдельное физическое устройство, эмулятор терминала, с которым мы, скорее всего, работаем - тоже отдельная программа, которой нужно наш запрос получить, обработать, вернуть результат. Всё это занимает какое-то время, а наша программа выполняется непосредственно на процессоре, и если попытаться прочитать ответ сразу, как только мы его запросили - ответа никакого ещё не будет, и мы подумаем, что терминал не стал нам отвечать. Поэтому, перед чтением ответа, нужно какое-то время подождать. За "подождать" отвечает функция usleep()
- она принимает один параметр, количество микросекунд, на которые мы хотим попросить операционную систему приостановить выполнение нашей программы. Нужно помнить две вещи, пользуясь функциями sleep()
, usleep()
и подобными: Первое - программа на это время останавливается, полностью, и ничего не делает - даже не загружает процессор, на это время операционная система про нашу программу забывает. И второе - Linux не является операционной системой жёсткого реального времени, и ОС'ь вовсе не обязана через запрошенное количество микросекунд программу разбудить, операционная система по истечении этого времени снова продолжит программу... когда-нибудь. Не раньше, но сколь угодно позже. То есть, не стоит использовать sleep
-функции для измерения времени. Даже в операционных системах жёского реального времени сам системный вызов sleep()
занимает какое-то время, какое-то время - остановка и возобновление программы, да и такт часов играет свою роль. Функции sleep()
используются, чтобы сказать операционной системе, что программе пока что нечего делать, вот совсем нечего - а не для того, чтобы программа отмеряла временные промежутки.
Далее остаётся считать ответ терминала - есть он или нет. Всё ещё помним, что функцией scanf()
в серьёзных, коммерческих, проектах пользоваться не стоит - по крайней мере, пока вы не осознаёте всех нюансов её использования). Восстанавливаем изначальный - ранее запомненный - режим работы терминала (скорее всего, он был каноническим, но это не важно - важно прибрать за собой, "вернуть как было").
И остаётся проверить, вернул терминал значения, или нет. Если нет - просто предположим, что соотношение высоты к ширине пикселя - 2 к 1, если да - посчитаем актуальное соотношение. Теперь можно вызвать функцию resize_screen()
, чтобы она, пользуясь полученным значением pixel_ratio установила корректное значение ratio - и всё, программа готова отрисовывать изображения с неискажёнными пропорциями!
int main()
{
init_player_input(quit_game);
main_cycle = 1;
init_graphics();
while(main_cycle)
{
init_screen();
int theta = t / DAMPING;
init_screen();
int model_size = sizeof(model)/sizeof(struct Triangle3d);
for (int i = 0; i < model_size; i++)
{
struct Triangle3d tmp;
tmp.p1 = rotate_Point3d_around_y(model[i].p1, theta);
tmp.p2 = rotate_Point3d_around_y(model[i].p2, theta);
tmp.p3 = rotate_Point3d_around_y(model[i].p3, theta);
draw_triangle3d(tmp, '*');
}
render_scene();
t++;
}
finalize_graphics();
return 0;
}
Углублённое задание для самостоятельной работы:
- Если в процессе работы начать менять размеры окошка эмулятора терминала, то рано или поздно программа словит сегфолт. Почему это происходит? Найдите, и - главное - устраните причину выхода за предел буфера изображения. (Подсказка: не стоит выполнять какие-либо действия когда ни попадя. Лучше (правильнее!) быстренько оставить себе метку о том, что требуется выполнить какое-то действие - и потом уже, когда будет для этого подходящий момент, сделать то, что надо)
- Осознайте, что парралельное программирование - это очень сложный (полный ловушек на своём пути) процесс. Пообещайте себе избегать в реальной работе парралельное программирование везде, где это возможно - но при этом изучить его со всеми его граблями очень хорошо, благо игровой движок - как раз такая программа, которая без парралельного программирования никак не обойдётся.