Найти в Дзене
ZDG

Язык программирования Rust: Снова-здорово

После разработки игры Apple я зарёкся что-либо писать на Rust, потому что это банально неэффективно. У меня в коде нет утечек памяти, чтобы Rust от них оберегал. А вот написание кода становится настоящей пыткой. Нашёл хорошее обобщение опыта (это значит, не стоит понимать его буквально): Умеете ли вы конструировать сложные библиотеки или приложения с нуля так, чтобы после 10 тысяч строк кода не получить ужасную красную волнистую линию, которая потребует переделать весь проект? Ужасная красная волнистая линия: Но всё же мне не давал покоя этот прошлый опыт, как будто я что-то не понимаю. К тому же некоторые практики из Rust я перенёс в другие языки, так что это оказалось чем-то полезно. Поэтому я решил дать Rust ещё один шанс и сделать очередную простую игру, но я не буду ничего писать, пока не разложу всё по полочкам. В данном случае меня интересует реализация графической подсистемы и то, что за ней потянется. Рассмотрим абстрактную игру. В ней могут быть десятки и сотни различных игро
Оглавление

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

Умеете ли вы конструировать сложные библиотеки или приложения с нуля так, чтобы после 10 тысяч строк кода не получить ужасную красную волнистую линию, которая потребует переделать весь проект?

Ужасная красная волнистая линия:

-2

Но всё же мне не давал покоя этот прошлый опыт, как будто я что-то не понимаю. К тому же некоторые практики из Rust я перенёс в другие языки, так что это оказалось чем-то полезно.

Поэтому я решил дать Rust ещё один шанс и сделать очередную простую игру, но я не буду ничего писать, пока не разложу всё по полочкам.

В данном случае меня интересует реализация графической подсистемы и то, что за ней потянется.

Рассмотрим абстрактную игру. В ней могут быть десятки и сотни различных игровых объектов, которые присутствуют на экране и взаимодействуют.

-3

Значит, первое, что нужно, это список для хранения игровых объектов.

Далее, на экране могут появляться новые объекты, а также исчезать существующие. Значит, этот список должен динамически меняться.

Поэтому первое, о чём я думаю, это компонент

Stage (сцена)

Он хранит список объектов и предоставляет интерфейс для их добавления и удаления, чтобы не заботиться о конкретной реализации списка.

Также Stage обладает представлением об игровом экране. Он знает его размеры и знает координаты и размеры каждого объекта в списке. Это позволяет ему определять, какие объекты видны, а какие нет.

Кроме того, Stage поддерживает иерархию объектов – чтобы они рисовались в определённом порядке, перекрывая друг друга правильно.

Далее нужен рендер.

Я использую графическую библиотеку SDL2, но если абстрагироваться, рендерер должен быть отдельный от игры. То есть надо иметь возможность подменить рендерер на DirectX, OpenGL или даже алфавитно-цифровой, и игра должна всё равно работать без переписывания остального кода.

Отсюда возникает требование к наличию второго компонента –

Renderer

Снаружи он даёт интерфейс методов рисования, которые всегда одинаковы, а внутри себя реализует доступ к методам конкретной низкоуровневой подсистемы, и знает, как подготовить данные объекта для этой подсистемы.

Например, рендерер OpenGL трансформировал бы битмапы в текстурированные полигоны, а алфавитно-цифровой рендерер брал бы RGB-значения пикселов битмапов и подбирал для них символы.

-4

Цикл рисования выглядел бы примерно так (это некий обобщённый язык программирования):

-5

И если бы все игровые объекты были, например, битмапами, то проблем бы не было.

Но объекты могут рисоваться по-разному. Я выделяю как минимум следующие типы: битмап, текст, прямоугольник (в общем случае – растеризация полигонов), эффект (процедурная графика типа дыма, взрыва, свечения и т.д.)

Соответственно рендерер должен иметь такие интерфейсные методы:

  • drawBitmap(obj)
  • drawText(obj)
  • drawRect(obj)
  • drawEffect(obj)

Тогда цикл рисования изменяется, опять же условно, так:

-6

Честно говоря, особой проблемы в этом не вижу. На рисование каждого объекта добавляется от одного до четырёх if, и в прогнозе мы имеем какие-то десятки или сотни лишних if на кадр. Не так чтоб смертельно для большинства случаев. Учитывая, что даже тупой цикл рисования одного битмапа 256*256 уже содержит 65536 if, дополнительная нагрузка не делает погоды.

И всё же я из принципа стремился получить такое решение, чтобы if были не нужны. Попытки уже делались в игре Apple, но так ничего и не вышло. Там пришлось использовать те же if, только в виде match:

-7

И не то чтобы я не старался, а оно действительно не получилось по-другому. Спустя время это выглядит довольно странно, потому что альтернативные решения существуют. Но вероятно был какой-то подвох, который сразу не видно.

С новыми силами

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

Проще всего было бы разделить список объектов по типам. Сначала рисуем все битмапы, потом все тексты, потом все эффекты и т.д. Такие сценарии могут быть, но в общем случае объекты разных типов перемешаны и должны выводиться в произвольном порядке.

Чтобы понять, как надо рисовать объект, надо узнать его тип. А это конечно ведёт к if. Очевидное решение – объект должен рисовать сам себя, потому что ему не надо проверять собственный тип.

Цикл рисования будет выглядеть так:

-8

Здесь каждый объект имеет собственный метод draw(), который и вызывается для рисования. В этот метод передаётся рендерер, чтобы объект мог использовать его для вызова определённого метода.

Например, у объекта типа Bitmap метод draw() будет вызывать renderer.drawBitmap(). У объекта типа Rect метод draw() будет вызывать renderer.drawRect(), и т.д.

Таким образом, if нет и все довольны. Но теперь объекты становятся разных типов. Это не один условный StageObject, а BitmapObject, TextObject, RectObject и т.д. У них всех есть метод draw(), и это могло бы прокатить в JavaScript или PHP, но в Rust не прокатит.

Мы теперь даже не можем иметь список объектов, потому что все они разного типа. Следовательно, надо их как-то обернуть в единый тип.

Сделать это можно с помощью трейтов, чтобы ссылаться не на конкретный тип объекта, а на его признаки (иначе говоря, интерфейс).

Определю трейт Drawable, которым должны обладать объекты:

-9

Теперь описываю типы DrawableBitmap и DrawableRect, которые будут обладать трейтом Drawable:

-10

У битмапа есть атрибут handle – это некий условный указатель на битмап, что сейчас неважно. У прямоугольника есть атрибут типа Rect, то есть собственно прямоугольник, но тоже неважно. Главное, что у разных типов разные атрибуты, которые должны использоваться по-разному, и именно это надо проверить.

Напишу реализацию трейта Drawable для обоих типов объектов:

-11

Метод draw() в каждом случае получает ссылку на рендерер и вызывает у него соответствующий данному объекту метод с соответствующими аргументами.

Теперь напишу конкретную реализацию Renderer.

-12

Атрибут canvas тут чисто условный, для создания видимости использования. Этот рендерер просто выводит сообщения, что рисует. Замечу, что рендерер может быть также оформлен как трейт, чтобы можно было динамически подставлять в draw() разные рендереры, но такой сценарий использования кажется бесполезным. Вряд ли приложение будет использовать более одного рендерера одновременно. Поэтому целевой рендерер фиксируется в исходном коде, и для его замены будет нужна перекомпиляция.

Теперь мы можем создать объект типа DrawableBitmap либо объект типа DrawableRect, но поместить их в один массив нельзя, так как они разных типов (а загвоздка даже не в типах, а в размерах). Вместо самих объектов в массив можно поместить ссылки на них, так как размер ссылки одинаков.

-13

И вот есть первый успех: объекты разного типа в одном списке, их можно брать из списка и рисовать каждый по-своему. Программа выводит на печать:

drawing Bitmap 5
drawing Rect 10x10

Это значит, что корректные методы рендерера вызвались с корректными параметрами.

Можно ли уже радоваться?

Нужно учесть, что ещё может происходить в игре. Помимо отрисовки самих себя, объекты ещё и как-то двигаются.

Попробуем поменять атрибуты объектов, конкретный смысл неважен, главное чтобы что-то изменилось:

drawables[0].handle = 1;
drawables[1].rect.w = 100;

И конечно, это не сработает, так как в массиве имеем только лишь ссылку на признак Drawable, с помощью которой можем вызывать метод draw(), но не знаем, что это за конкретный тип объекта, и какие у него есть атрибуты.

С другой стороны, все экранные объекты обладают рядом одинаковых свойств. Например, это экранные координаты x, y. Мы могли бы эти свойства выделить в некий общий объект, и добавить к этому общему объекту ссылку на Drawable. Попробуем:

-14

Теперь экранный объект это структура StageObject, которая содержит ссылку на трейт Drawable, который знает, как себя рисовать. Напомню, что в таких случаях Rust требует указывать время жизни, и 'a означает: есть структура StageObject с временем жизни 'a, которая содержит ссылку на Drawable, у которой время жизни не меньше чем 'a.

Создадим массив из объектов StageObject:

-15

Теперь массив содержит не ссылки, а сами объекты, так как тип StageObject имеет фиксированный размер. А вот объекты уже содержат ссылки, как раньше. Рисование будет происходить так:

objects[0].drawable.draw(& mut renderer);

Теперь попробуем изменение атрибутов в StageObject:

objects[0].x = 1;
objects[1].y = 100;

Оно, конечно, тоже работает, ведь атрибуты StageObject компилятору известны.

Поведение

С рисованием стало понятно, но игровые объекты должны ещё как-то себя вести. Когда они себя как-то ведут, это обычно отображается на экране. То есть у StageObject должна, к примеру, меняться координата x. Это значит, что поведение объекта как-то должно быть связано со StageObject, чтобы влиять на него. Здесь Rust может преподнести сюрприз.

Для начала, нужно ли расширять непосредственно StageObject какими-то состояниями поведения? Думаю, нет, так как ответственность надо разделять. StageObject нужен исключительно для отображения объекта на сцене. Сами же игровые объекты могут работать вообще без отображения.

Поэтому сделаю новый тип: GameObject. Эти объекты тоже будут храниться в списке.

Сейчас достаточно рассмотреть минимальную структуру GameObject, которая имеет ссылку на StageObject:

-16

Ссылка нужна, чтобы связать каждый GameObject с его отображением StageObject, и она должна быть мутабельной, чтобы можно было изменять атрибуты StageObject для изменения его положения на экране и т.д.

Создадим StageObject, GameObject и свяжем их:

-17

Благодаря мутабельной ссылке логика GameObject может менять параметры в StageObject. Но эта лёгкость обманчива.

Ведь StageObject надо ещё добавить в список объектов сцены. Пока просто создадим массив из одного элемента с объектом sto:

let mut stage_objects: [StageObject; 1] = [ sto ];

В языке C это прошло бы без проблем, но тут возникает ошибка:

-18

Дело в том, что помещая переменную sto в массив, мы передаём владение этому массиву, то есть переменная становится недействительной. Но мы уже заняли (borrow) мутабельную ссылку на неё. Значит, пока существует ссылка, владение передать нельзя.

В массив также можно было поместить ссылку, а не сам объект, тогда владение бы не передалось. Но мутабельную ссылку мы уже получили, поэтому вторую получить нельзя. Это тупик.

Попробуем по-другому. Сначала поместим объект в массив, а затем возьмём ссылку на элемент массива:

-19

Так оно работает, но только один раз. Поместить в массив второй элемент и взять на него ссылку мы уже не можем, потому что ссылка блокирует весь массив.

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

Зрим в корень

С точки зрения Rust ссылки это зло. Нельзя иметь более одной мутабельной ссылки на данные, а чаще всего нам нужна именно мутабельная, так как нужно что-то менять.

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

Отсюда вывод – ссылки должны создаваться локально и временно, и сразу же освобождаться.

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

Можно ли не хранить ссылку в GameObject, а получать её только тогда, когда она нужна?

Так как StageObject лежат в массиве, попробуем хранить не ссылки на них, а индексы массива.

-20

Теперь, когда нам требуется получить доступ из GameObject к StageObject, мы будем получать его по индексу массива:

-21

И всё работает, и мы даже обошлись даже без временных ссылок. По крайней мере одна проблема решена, но можно ли это решение масштабировать дальше?

Теперь для обработки GameObject нужен доступ ко всему массиву stage_objects, то есть условная процедура обработки всех объектов будет выглядеть так:

-22

Она принимает мутабельную ссылку на массив game_objects и мутабельную ссылку на массив stage_objects. Внутри процедуры всё работает, потому что мы не создаём новые ссылки. После завершения процедуры мутабельные ссылки освобождаются и мы чисты перед законом Rust.

Предположим, нам надо из этой функции вызвать другую функцию, которой тоже нужна будет мутабельная ссылка на массив объектов. Мало ли что. Надо проверить, что будет:

-23

Теперь мы из process_game_objects() вызываем функцию process_test(), передавая туда мутабельную ссылку на массив stage_objects, и это прекрасно работает. Ведь мы не создаём новых ссылок.

Вернёмся к обработке конкретных типов поведения.

Трейты поведения

Предположим, есть два простейших типа поведения: двигаться и ждать. При движении должна меняться координата у StageObject (это уже получилось реализовать), а при ожидании нужно увеличивать счётчик, который является состоянием поведения.

Чтобы обработать поведения игровых объектов, нужно также различать их по игровому типу. Условный код:

-24

Здесь видим полную аналогию со StageObject, и решение может быть аналогичное с использованием трейтов-поведений. Но в отличие от отображения, чаще всего обработка не требует смешанного порядка. Можно спокойно обработать все пули, потом всех монстров и т.д. Так что список объектов можно разделить по типам и каждый тип обработать отдельно с уже известным поведением:

-25

Единственное, что придётся поддерживать несколько списков по типам объектов.

Можно всё-таки попробовать реализовать поведения для единого списка, чтобы посмотреть на возможные подводные камни.

Идти будем тем же путём, как для Drawable: у каждого типа поведения будет трейт Behaviour с интерфейсным методом update().

-26

Метод update() должен получать мутабельную ссылку на GameObject, чтобы поведение могло менять GameObject, но на самом деле это для иллюстрации, что так делать нельзя, что мы позже и увидим.

В GameObject добавляем собственный атрибут x, чтобы было что менять, и также мутабельную ссылку на трейт Behaviour:

-27

Пока опущу код конкретных реализаций трейта Behaviour. Теперь, теоретически, обработка игровых объектов будет происходить так:

-28

Но конечно возникает ужасная красная волнистая линия:

-29

Происходит вот что:

  1. behaviour.update() требует мутабельную ссылку на behaviour, потому что в сигнатуре метода присутствует &mut self.
  2. Несмотря на то, что есть мутабельная ссылка на gmo, можно получить мутабельную ссылку на часть gmo, а именно на gmo.behaviour. Это работает, потому что можно брать ссылки на части структуры, которые не пересекаются в памяти.
  3. В метод behaviour.update() передаётся мутабельная ссылка на behaviour, и мутабельная ссылка на весь объект gmo.
  4. Это значит, что через переданную ссылку на gmo мы можем получить доступ к behaviour и тогда память у этих двух переданных ссылок окажется пересекающейся. Что недопустимо.

Чем помочь?

Принципиальная проблема в том, что создаются ссылки, которые ссылаются сами на себя. Из gmo берётся behaviour, а в behaviour.update передаётся снова gmo, из которого снова можно взять behaviour, и так можно продолжать до бесконечности. Компилятору такое не нравится, он не может за всем проследить, и потому просто запрещает.

-30

Тогда надо поменять структуру GameObject.

Выделим атрибуты GameObject в отдельную структуру GMOData:

-31

И добавим эту структуру в GameObject в виде атрибута data:

-32

И теперь будем передавать в update() не ссылку на gmo, а ссылку на gmo.data:

-33

Конечно, трейт Behaviour надо соответственно изменить:

-34

И это работает! Теперь из gmo берётся ссылка на behaviour, и другая ссылка на data, и они не пересекаются и не ссылаются обратно на gmo. Порочный круг разорван.

Это далеко не конец истории, но пора закругляться. Дальше буду рассматривать реализацию списка объектов.

-35

Читайте дальше: