Урок 3

В котором на экран выводится анимация!
- Если очень быстро бежать мимо картин
Айвазовского, получится мультик про море

Итак, современные операционные системы устроены так, что нет простого решения для вывода изображения на экран. Любая задача имеет бесконечное количество решений (другой вопрос что не все эти решения подходящие или даже допустимые, но тем не менее они составляют множество {решения}), обозначим некоторые из них:

  • самое распространённое (да и правильное, чего уж там) при необходимости вывода изображения на экран - использование специальной библиотеки, которая возьмёт на себя все работы по взаимодействию графических подсистем.
  • Другое решение - работать с графическими API непосредственно, но и здесь нужно будет использовать несколько библиотек - чтобы попросить графическую подсистему открыть и отдать нам окно, а также чтобы получить физические адреса функций видеокарты. Мы придём к этому решению, и научимся использовать OpenGL - но рисовать графику хочется уже здесь и сейчас, а даже собрать (слинковать) программу, чтобы она заработала с видеокартой не такая уж и очевидная задача для человека, который с ней ещё не сталкивался, да и код (что уж говорить про объяснение концепций), который требуется для вывода одного треугольника в современном OpenGL (или, тем более, в Vulkan) занимает немало строк, причём в программе должны быть отдельные программы для видеокарты - шейдеры, и отдельно (прямо в нашей программе!) нужно эти программы в видеокарту загрузить, там их скомпилировать, слинковать, проверяя возможные ошибки, и использовать эту программу в видеокарте из нашей программы. Для обучения программированию графики до сих пор иногда применяют старый OpenGL, в котором общение с видеокартой сводилось к вызову команд навроде "нарисуй треугольник по таким-то координатам", но такой подход не освобождает от необходимости использования библиотек для взаимодейсвий с ОС и видеокартой, и конечно же накладывает ограничение на возможности современной графики.
  • Так же возможно для вывода графики воспользоваться старой операционной системой, которая даёт возможность записи из программы непосредственно в видеопамять.
  • Либо, самый лёгкий из возможных вариантов - использовать готовый игровой движок. Если вы хотите сделать, доделать игру, а не просто писать код ради процесса написания - вполне возможно что вам стоит смотреть на Юнити, анриалЭнджин и прочие Годоты, а не на этот сайт.
  • Ещё есть вариант с написанием программ для специальных компьютеров, предназначенных для лёгкого создания именно игр, заточенных именно под этот процесс. Возможно, это то, чего вы хотели, но не осознавали до сих пор. Попробуйте поиграйте в игрушки для восхитительнейшего виртуального ретро-компьютера PICO-8, его волшебное очарование просто не может оставить равнодушным... Кстати, один из лучших, эмоциональных и затягивающих платформеров, Celeste, был сначала создан в рамках Геймджема для PICO-8, и потом расширен множеством контента до коммерческой игры, вышедшей на актуальных платформах. Даже если цена в 10 евро каким-то чудом сможет вас остановить от покупки PICO-8, всегда можно попробовать создавать игры для него в "PICO-8 Education Edition или заняться созданием игр для его свободных аналогов (PIC-80 в первую очередь, конечно же).

Но есть и другой вариант! Ведь мы уже умеем осуществлять вывод на экран - пускай и через эмулятор терминала. Строка букв - это же линия! А терминал выводит строки текста последовательно, и если мы (а в терминале используется моноширинный шрифт) знаем количество строк, одновременно отображаемых в терминале и количество символов в строке - то мы можем генерировать такую строку, что сможем рисовать на двухмерной плоскости терминала символами как минимум линии, а так же и другие геометрические фигуры. Используя то, что изображение разных символов состоит из разного количества пикселей (квадрат с белой '.' намного темней, чем квадрат с символом '#', к примеру) мы сможем и раскрасить полученную картинку градациями серого. Попробуйте написать программу, которая выводит на экране (эмулятора) терминала линию: Получилось? Вот как я это сделал:

#include <stdio.h> int main() { printf(" \n"); printf(" #-_ \n"); printf(" -_ \n"); printf(" # \n"); printf(" \n"); return 0; }

Если нужно нарисовать одну конкретную линию, то такой вариант имеет право на существование. Но наша задача - отрисовывать прекрасные фантасические миры, то есть задачу нужно решить в общем случае. Для этого будем пользоваться не готовыми строковыми литералами, которые непосредственно будут передаваться в функцию, а создадим массив (одномерный, но напишем обёртку для работы с ним как с двухмерным) - массив будет соответствовать экрану терминала, а запись значений в ячейки массива будет соответсвовать рисованию точек на экране. Попробуйте решить задачу самостоятельно, и после сравните с приведённым здесь кодом:

#include <stdio.h>

#define WIDTH 80
#define HEIGHT 24

char screen[(WIDTH + 1) * HEIGHT]; /* One extra char for linebreak */


int main()
{
    for (int y = 0; y < HEIGHT; y++)
    {
        for (int x = 0; x <= WIDTH; x++)
        {
            if (x == WIDTH)
            {
                screen[(WIDTH + 1) * y + WIDTH] = '\n';
            }
            else if (x == y)
            {
                screen[(WIDTH + 1) * y + x] = '#';
            }
            else
            {
                screen[(WIDTH + 1) * y + x] = '.';
            }
        }
    }

printf("%s\n", screen);

return 0;
}
Пока что размер массива, который выступает буфером экрана (содержит изображение для вывода на экран) задаётся во время компиляции и не может быть изменён во время работы программы. Если экран терминала, на котором будет запущена программа, больше предполагаемого размера - ничего страшного, просто изображение будет выводиться не во всё окно. Если же количество символов, помещающихся в терминал, меньше - артефакты разрушат изображение.

Создайте процедуру для записи пикселя (символа) в изображение. Заодно вынесите инициализацию массива символами в отдельную функцию. Тогда программа целиком выглядеть может, например, так:

#include <stdio.h>

#define WIDTH 80
#define HEIGHT 24


char screen[(WIDTH + 1) * HEIGHT]; /* One extra char for linebreak */


void init_screen()
{
    for (int y = 0; y < HEIGHT; y++)
    {
        for (int x = 0; x < WIDTH; x++)
        {
            screen[(WIDTH + 1) * y + x] = ' ';
        }
        screen[(WIDTH + 1) * y + WIDTH] = '\n';
        }
    screen[(WIDTH + 1) * HEIGHT - 1] = '\0';
}


void draw_pixel(int x, int y, char p)
{
    if (x < WIDTH && y < HEIGHT)
    {
        screen[y * (WIDTH + 1) + x] = p;
    }
}


int main()
{
    init_screen();

    draw_pixel(10, 7, '#');
    draw_pixel(11, 7, '#');
    draw_pixel(19, 7, '#');
    draw_pixel(20, 7, '#');
    draw_pixel(10, 8, '#');
    draw_pixel(11, 8, '#');
    draw_pixel(19, 8, '#');
    draw_pixel(20, 8, '#');
    draw_pixel(10, 17, '#');
    draw_pixel(11, 18, '#');
    draw_pixel(12, 19, '#');
    draw_pixel(13, 19, '#');
    draw_pixel(14, 19, '#');
    draw_pixel(15, 19, '#');
    draw_pixel(16, 19, '#');
    draw_pixel(17, 19, '#');
    draw_pixel(18, 19, '#');
    draw_pixel(19, 18, '#');
    draw_pixel(20, 17, '#');

    printf("%s\n", screen);

    return 0;
}
Большое дело сделано!

Мы создали способ рисовать на экране пиксели, значит теперь можно из пикселей нарисовать любую картинку - и значит, что теперь есть удобный инструмент для создания анимации. Попытайтесь самостоятельно анимацировать вывод программы, и отметьте для себя проблемы, с которыми довелось столкнуться. Из-за чего они и как их возможно разрешить?

#include <stdio.h>

#define WIDTH 80
#define HEIGHT 24
#define DAMPING 500  


char screen[(WIDTH + 1) * HEIGHT]; /* One extra char for linebreak */


void init_screen()
{
    for (int y = 0; y < HEIGHT; y++)
    {
        for (int x = 0; x < WIDTH; x++)
        {
            screen[(WIDTH + 1) * y + x] = ' ';
        }
        screen[(WIDTH + 1) * y + WIDTH] = '\n';
    }
    screen[(WIDTH + 1) * HEIGHT - 1] = '\0';
}


void draw_pixel(int x, int y, char p)
{
    if (x < WIDTH && y < HEIGHT)
    {
        screen[y * (WIDTH + 1) + x] = p;
    }
}


int main()
{
    int t = 100000;
    printf("\033[?1049h");  /* Enables buffer mode in terminal */
    printf("\033[?47h");    /* Tells terminal to save screen */
    printf("\033[?25l");    /* Hide cursor */
    printf("\033[2J");      /* Erase console screen */

    /* Main cycle */
    while (t--)
    {
        init_screen();  /* Erase old image from buffer */

        for (int x = 1; x < WIDTH - 1; x++)
        {
            int y = HEIGHT * x / WIDTH;
            int x_tmp = (x + t / DAMPING) % WIDTH;

            draw_pixel(x_tmp, y, '#');
        }

        printf("\033[H");    
        printf("%s", screen);
    }
    printf("\033[?25h");    /* Restore cursor visibility */
    printf("\033[?47h");    /* Restore pre-program screen */
    printf("\033[?1049l");  /* Disables buffer mode in terminal */
    
    return 0;
} 
(Измененилась только тело функции main(), и добавилась в начале программы директива препроцессора #define DAMPING 500, которая понадобится в main(). Процедуры init_screen() и draw_pixel() не изменились)

Эта программа - итог сегодняшнего урока, она выполняет именно то, что представлено на видео в начале урока. Какие пробелмы возникли и каково их решение?

Самое первоочередное - чтобы создать анимацию, необходим способ измерять время. Анимация - последовательность статичных изображений, для создания анимации в один момент времени нужно отрисовывать одно изображение, в следующий момент времени - уже следующее изображение. Но такая простая программа как приведённая выше на современных компьютерах работает слишком быстро, следующий кадр будет нарисован хоть и позже предыдущего, но настолько быстро, что человек (да и монитор, он тоже не предназначен для таких быстрых изменений изображения) не успеет осознать, увидеть разные кадры, и они сольются в мельтешащую мешанину. Чтобы этого не произошло, нужно определить, как часто мы хотим сменять картинки и, соответственно, нужен способ отмерять время - чтобы проверять, наступил ли уже момент для следующего кадра, или нет? При создании очень старых игр раньше для измерения времени использовали время работы программы - тогда ещё можно было ориентироваться на то, для какого конкретно процессора (с какой скоростью работы) создавалась игра, было примерно понятно, с какой скоростью проходила обработка программы для отрисовки одного кадра игры - из этого делался вывод, какое количество времени проходит между кадрами и, соответственно, насколько нужно сдвигать все подвижные объекты в игре каждый кадр. Но такие игры, будучи запущенными на более мощных компьютерах (с более быстрым процессором) становились неиграбельны - всё начинало происходить настолько быстро, что играющий просто не успевал среагировать, как вбегал во врага и умирал, только нажав кнопку "вперёд" на старте уровня. Поэтому в любой игре с тех пор используется информация с часов компьютера, которая, кстати, хоть и может быть получена и без обращения к операционной системе, но всё же пока не является критичной для нас. Мы воспользуемся старым подходом, и пока что будем ориентироваться в смене изображений на количество пройденных циклов программы.

Перед началом работы нужно определить, сколько циклов должна проработать программа - так как пока мы не умеем принимать сигналов(буквально!) извне, и заранее решить, сколько программе длиться - единственный пока способ сделать, чтобы она не работала бесконечно. Вы можете подобрать количество циклов опытным путём, на свой вкус и на свою скорость процессора. Если вы здесь поставите слишком большое число или неверно укажете декремент, программа может не закончится очень долго - и чтобы закрыть "повисшую" программу, работающую в терминале, в Linux можно нажать Ctrl+C. Эта комбинация клавиш приводит к тому, что программе отправляется сигнал с указанием завершится - и программа остановится, вернув управление. А вот если закрыть окно терминала с работающей программой, программа совершенно не обязана останавливаться. При закрытии вирутальный терминал, конечно, пошлёт об этом сигнал программе, но если она и в самом деле повисла, или предполагает, что завершать её работу должны каким-то другим образом, реагировать она на простое закрытие окна совершенно не обязана, и вполне может так и продолжить свою работу - ведь программа работает внутри компьютера, а терминал - это всего лишь печатная машинка, подключенная к компьютеру. Закрытие окна терминала можно сравнить с выключением монитора - хоть работу программы больше не видно, но не факт что она при этом и в самом деле перестала работать.

Заведем переменную t - она будет соответсвовать количеству оставшихся циклов работы программы.
Используем t, чтобы сдвигать координату X каждой точки:
x = x + t;
При таком варианте линия будет сдвигаться слишком быстро, внутри цикла содержится довольно мало процессорных инструкций (и, опосредованно, системных вызовов) - и переменная t изменяется намного быстрее, чем происходит обновление картинки на мониторе. Замедлить смещение можно просто уменьшив смещение, как бы банально это не звучало:
x = x + t / 500;
Значение 500 является "магическим числом" - значением, которое написано в коде непосредственно цифрами. Использовать "магические числа" в коде - плохой тон (в любом правиле возможны исключения, но обычно - каждое число в коде, кроме -1, 0, 1 или 2 - скорее всего, должно быть как-то вынесено из непосредственно тела функции). Подобрано число 500 именно для моей, конкретной системы, и для вас может оказаться необходимым использовать другое значение. Постоянно держим в голове, что сама переменная t (между прочим с плохим, однобуквенным названием, лучше "cycle_count" или "remaining_ticks") - не более чем хак, и в любой программе чуть серъёзней нашего игрушечного примера нужно использовать значение, полученное от часов - непосредственно от процессора, или от операционной системы.

Наконец, заметим, что t принимает значения от 0 до 100000, значит смещение (t / 500) - от 0 до 200. С шириной консоли в 80 символов-пикселей это значит, что часть линии не покажется на экране практически никогда, да и сама линия в основном не будет видна - пытаясь отрисоваться правее экрана, чего процедура draw_pixel() не делает. Чтобы анимация всегда была на экране, достаточно закольцевать экран по ширине - делить значение x по модулю шинины экрана:

x = (x + t / 500) % WIDTH;

Анимация готова, но вывод анимации на экран - не такая простая задача, если не представлять специфики устройства вывода информации, с которым выбрано работать.

Так как основное предназначение терминала (всё ещё держим в голове, что это может быть и просто электрическая печатная машинка-телетайп) - ввод и вывод строк текста, но не анимации, то если попытаться просто вывести подготовленную строку с изображением, и тут же вывести следующую, со следующим кадром анимации, и следующую... То можно увидеть (а можно и не увидеть, как повезёт с терминалом и командным интерпретатором) артефакты изображения - от безобидных, промаргивание тут и там курсора, через досадные - подрагивание изображения, прыжки вверх/вниз на одну строку, вплоть до мешающих - изображение разваливается, верх выводится внизу, низ - сверху...

Первое, что нужно предпринять - рисовать каждый кадр анимации всегда из одной точки, в нашем случае (так мы создавали наш буфер изображения и так устроен терминал, слева направо сверху вниз) - из левого верхнего угла. Терминалы с давних пор умели выполнять специальные команды - не только "напечатать букву" и "перевести строку", и, конечно "звякнуть звонком, чтобы человек поблизости обратил внимание на телетайп", но и показавшиеся кому-то когда-либо полезными вспомогательные, навроде "затереть предыдущий символ", "перейти на предыдущую строку" и даже "начать печатать другим цветом". Для начала воспользуемся возможностью перемещать курсор.

Специальные символы в телетайпе передавались с помощью отдельных клавиш (которых на наших клавиатурах нет, и нет даже возможности ввести эти символы в текстовом редакторе) Когда телетайпы стали заменяться терминалами, производители терминалов стали реализовывать в терминалах всё новые и новые функции, количество возможностей уже превышало возможности разумным образом добавить ещё специальных кнопок на клавиатуру. В то же время, эти функции стали настолько сложны (например, установить другой цвет печати можно выбрав из многих (8, это много по сравнению с одним чёрным) цветов - и несколько таких функций с несколькими параметрами каждая уже не помещались в 256 значений одного бита. А ведь основная функция терминала - передавать туда и обратно по линии связи символы, по одному биту, и чтобы терминалы оставались совместимы с телетайпами, чтобы можно было подключить с одной стороны линии телетайп, с другой - терминал, и общаться текстом через них. Такая ситуация привела к решению в виде Escape-последовательностей: сначала в линию посылается код символа escape ( код 27 или, в восьмиричном представлении - 033) - и тогда парное устройство с той стороны линии знает, что следующий символ будет не символом, который нужно напечатать, а началом команды. Команды, которые занимают больше одного байта, обычно начинаются символом '[', так, команда поставить курсор в 5 строку на 8 позицию выглядит так: esc[5;8H

Символы, которые окажутся в линии после символов команды, будут напечатаны, но уже начиная с новой позиции. Команда же esc[0;0H может быть воспринята и в своей сокращённой форме: esc[H.

И виртуальный терминал (текстовый режим работы компьютера), и эмулятор терминала (в графическом окне) - в основном поддерживают escape-последовательности, но - не все. Нужно учитывать, что не каждый терминал, на котором будет работать ваша программа, всегда выполнит вашу команду. И для работы (если вы и в самом деле решите писать серьёзную программу, выводящую графику в текстовой консоли) всегда стоит использовать специальные библиотеки (в частности, конечно же, в первую очередь ncurses) - библиотеки позволяют реализовывать функционал без оглядки на конкретное железо, с которым будет работать программа, и реализуют недостающий функционал в железе наилучшим подходящим для каждого конкретного терминала способом.

Мы же пишем программу только для своего личного и конкретного эмулятора терминала, и поэтому можем сами отследить нюансы работы с ним, а значит отправляем escape-последовательности непосредственно из нашего С-кода. Для того, чтобы в строковом литерале (строка текста в двойных кавычках в коде) записать символ "escape", в строке указывается код символа, перед которым ставится обратный слеш (как запомнить, какой слеш прямой, какой обратный? прямой и обратный слеш в шутку называют 'в гору' и 'с горы', /\, сначала - прямой, потом - обратный)

Итак, такой вызов функции: printf("\033[H"); приведёт к тому, что последующий вывод на стандартный выход будет начинаться с левого верхнего угла терминала. Ну а потом, когда мы выведем весь наш буфер, нужно будет снова (и снова) переместить курсор в левый верхний угол.

Проблема с мельтешащим время от времени курсором (если она вам встретилась) решается подобным образом: мы говорим терминалу перестать отрисовывать курсор командой esc[?25l

Так как мы начинаем рисовать в левом верхнем углу экрана, где, вполне возможно, в процессе предыдущей работы появился какой-то вывод, наша анимация перекрывает этот вывод. Само по себе это не страшно, но проблема возникает, если текущая высота (в символах) больше, чем ожидает программа. Тогда после работы программа оставит курсор сразу после своего рисунка, посреди старого текста. И текст, который будет набран после работы нашей программы - визуально окажется смешан со старым выводом. Решением было бы перед работой программы запомнить, где находился курсор (если терминал это поддерживает) - с тем, чтобы после завершения установить курсор на ту позицию. Однако, в таком случае ситуация с отрисовкой анимации прямо посередине старого текста остаётся. Легче всего перед началом отрисовки анимации стереть всё с экрана, и так же стереть всё после завершения анимации, оставив пользователю курсор в левом верхнем углу пустого теперь экрана, но можно поступить и иначе: некоторые терминалы (а с ними - и эмуляторы терминалов) поддерживали буферный режим работы. Изображение можно было выводить непосредственно на экран, а можно - в буфер, и переключать вывод с одной картинки на другую - тогда изображение с экрана отправлялось в буфер терминала, а отображалось бывшее до этого невидимым в буфере изображение.
Включается буферный режим работы командой esc[?1049h

Теперь, когда у терминала активирован буфер, можно сказать терминалу "сохранить изображение" - переключить буферы, чтобы изображение с экрана оказалось сохранено в буфере до будущих времён, а новый экран - очистить и начать работу "с чистого листа".

Вот и всё, мы готовы рисовать анимацию, и после подготовки терминала - теперь можно запускать наш while().

Кстати, то, что в программе выше происходит в цикле while() - называется главным циклом программы. Если тело функции main() является непосредственно программой, то внутренности while() - это та часть программы, которая работает всё время, повторяясь снова и снова, пока пользователь не захочет закрыть программу. То, что находиртся до цикла while() - это инициализация, здесь проиходят те подготовительные процессы, которые нужны в работе главного цикла, а всё, что после тела while() - это завершение программы, освобождение использованных ресурсов, например корректное закрытие графических окон.

Какие ресурсы нужно освободить нашей программе? Никакие, но тем не менее "прибрать за собой" есть что. Мы же переводили терминал в нестандартный режим работы, как минимум после работы программы в терминале теперь не видно курсора! Программа перед своим завершением всё ещё должна:

  • Вернуть старое изображение из буфера:
    esc[?47h
  • Выключить буферный режим работы:
    esc[?1049l
  • И снова включить отображение курсора:
    esc[?25h

Обратите внимание, что если прервать программу по Ctrl+C, то программа завершится не успев восстановить исходное состояние терминала, что не критично для нас сейчас, но в общем случае - довольно большая проблема, которую нужно соответсвенно решать при написании программ.
Настройки терминала, которые ему могла изменить наша программа, сбрасываются командой "reset".


Самостоятельная работа:
  • Размер переменных. Размеры типа. Почему цикл while (t++ < 1000000000) никогда не завершится? А при каких условиях он может завершиться?
    При каких условиях "повиснет" цикл while (t-- >= 0)?
  • Запустите вашу программу не в эмуляторе терминала (графическое окошко в оконном менеджере), а в консоли (в которую можно переключится, например, нажатием Ctrl+Alt+F2). Возможно, поведение программы вас удивит. Отчего такая разница? Какие из этих проблем решает приведённый выше код, а какие - нет? И почему? (Как из консоли в вашей ОС вернуться в графический режим вы должны знать сами, но, скорее всего это Alt+F7 или Alt+F1)
  • Разберитесь с сигналами. Как убить зомби? Подумайте, не стоит ли вернуться в мир windows, где службы и иконки, и больше не пользоваться linux - где только демоны и зомби.