- BrainTools - https://www.braintools.ru -

Поиграем на ПЛИС?

Введение

На Хабре и на других площадках полно различных статей, связанных с разработкой на ПЛИС, но не сложно заметить, что большинство статей, как правило, несут реализацию сугубо технических идей. Реализация умножителей, сумматоров, мультиплексоров, различных интерфейсов, ЦОС, и многое другое…  А как насчет игр?

Разумеется, я говорю не про эмуляцию приставок, а про реализацию непосредственно на одном из языков описания аппаратуры, например Verilog/SystemVerilog.

Я давно уже порывался реализовать одну из понравившихся игр, но понял, что чем интереснее игра, тем сложнее архитектура на ПЛИС, а чем сложнее архитектура, тем меньше вероятности, что ее кто-то захочет запустить у себя дома и поиграть. Одна из таких игр была DoodleJump, предшествующие ей статьи были про VGA [1] и SDRAM [2], оставалось реализовать интерфейс управления и доделать функционал самой игры, а то главный герой только лишь прыгал по зеленым квадратикам. И тут появляется НО(!). Но я понял что код мой перегружен, ресурсов ПЛИС уже не хватало(речь про BRAM), а сам код становился слишком монструозным и с огромным количеством ассинхронщины, при желаемой частоте в 100МГц. В итоге я отказался от проекта, и оставил идею на потом.

Кстати можно посмотреть фото того, что получалось на тот момент:
Вывод объектов на экран и оценка их отрисовки по координатам

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

Взаимодействие ГГ с объектами. ГГ циклично падал сверху в низ и должен был останавливаться на островках. Островки в свою очередь двигались горизонтально от края до края.
Добавлены прыжки

Добавлены прыжки

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

Постановка задачи

В результате долгих размышлений у меня сложились некоторые условия той игры, которую бы я стал реализовывать:

  • Игра должна быть написана без привязке к конкретной ПЛИС(то есть чистый Verilog/SystemVerilog).

  • Игра должна быть сложнее чем теннис, где нужно «ракеткой» отражать летающий мячик.

  • Игра должна быть с различными режимами игры, для того чтобы было интересно ее не только запустить, но и поиграть с разными параметрами.

  • Игра должна (но уже не обязана), потреблять по минимуму ресурсов чтобы ее можно было собрать под большинство ПЛИС. К сожалению, однозначно этот пункт я не решил.

И буквально пару недель назад я наткнулся на игру ColorFlood на Android, игра не замысловатая, имеет несколько режимов, с простой графикой. И я понял, что это то, что подходит под мои требования.

Разумеется, начнем с ролика игры. На видео ниже показан игровой процесс, демонстрируется один из трех режимов игры, игра с ПЛИС:

И тоже видео на Ютубе: https://youtube.com/shorts/NrknB0c329g [3]

Проект реализовывался под плату Nexys a7(должна быть совместима с Nexys 4 DDR). Сам он лежит на гитхабе: https://github.com/mifa1234/fpga_color_flood [4].

Реализация игры

Поиграем на ПЛИС? - 4

Основной задачей стояла реализация фрейм буфера, без использования BRAM памяти [5]. Использовать в упор массив размером в экран является тупиковым размышлением, так как планировал брать разрешение не ниже 640х480 пикселей, с форматом цвета RGB444. Так как игра ColorFlood подразумевает работу с квадратиками, то я нашел решение разбить весь монитор на квадратики по 32х32 пикселя, это как раз младшие 5 бит для счетчика ширины и высоты экрана. А старшие разряды можно использовать как индексы массивов. К тому же после разбиения экрана на квадраты по 32х32 пикселя, получается сетка 20х15 квадратов. А уже массив такого размера кажется более жизнеспособным. Я рассматривал и другие варианты 16х16, 8х8, но они слишком мелкие, да и в самой игре ColorFlood самое маленькое игровое поле имеет 12х12 квадратов, в моей реализации игровое поле будет 15х11.

Хорошо, с размером определился, теперь по аналогии с игрой в смартфоне нужно реализовать способы управления. По счастливой случайности [6] при размещения игрового поля слева остается три свободных столбца, в средний столбец я разместил доступные цветные квадраты для выбора хода. Выбор хода осуществляется двумя кнопками: одна меняет выбор квадратика, подсвечивает это обводкой контура, а вторая кнопка подтверждает выбор и запускает ход. Для удобства игры я реализовал логику [7] для двух кнопок управления выбором, вверх и вниз, но обе кнопки позволяют переключать выбор по кругу, в разном направлении.

Так как я не планировал делать поддержку шрифтов, а вывод прогресса нужно как-то делать, ведь не самому же квадратики пересчитывать, я реализовал прогресс бар внизу экрана. Эта зона делится на три части: слева к центру заполняется прогресс бар от первого игрока, справа к центру заполняется прогресс второго игрока (или ПК), в середине остается свободна серая область, которая постепенно уменьшается. В конце игры трекбар игрока, который победил начинает моргать обводкой.

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

Ёлочку реализовал ради наступающего нового года)

Режимы игры

Как уже стало понятно у этой игры есть несколько режимов игры:

  • Одиночный режим – когда игрок пытается захватить все поле за минимальное количество ходов.

  • Игра с ПК – в данном случае корректнее говорить «игра с ПЛИС», в этом режиме участвуют два игрока, один игрок человек, второй игрок – алгоритмы ПЛИС, они по очереди выбирают цвет и ходят.

  • Игра в двоем – предполагается, что будет играть два человека, по очереди передавая друг другу управление. В этом режиме подсвечивается кто ходит.

Если с первым и третьим режимами понятно, то во втором режиме стоит уделить внимание [8] алгоритму выбора цвета самой ПЛИС. В коде реализовано два уровня сложности, в первом случае выбирается первый доступный цвет, во втором случае сначала происходит поиск, где ПЛИС находит цвет с максимальным последующим присоединением квадратов. Этот выбор определяется параметром ENABLE_SIMPLE_AI.

Короткий пример:

Пример расчета выгодного хода. Уже можно отметить работу трекбара.

Пример расчета выгодного хода. Уже можно отметить работу трекбара.

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

Тогда ПЛИС в случае  ENABLE_SIMPLE_AI == 1, начнет считать количество соседствующих квадратов и выбирать с максимально выгодным вариантом. Таких будет два цвета, красный и голубой, тут сразу стоит отметить, что алгоритм не глубокий и ищет только соседей, поэтому голубых квадратов только 3, а не 4. Красных тоже будет насчитано три. И так как процесс идет итеративно, то последним проверяемым цветом будет зеленый (выбор идет сверху в низ от последнего положения), а это значит, что красный будет обладать последним максимальным значением. Почему не первое? А потому что я так реализовал логику и обошел вариант, когда все варианты выбора буду между собой могут быть равны и равны нулю (например, самое начало игры, когда из соседей только синий, а синий выбрать нельзя, так как он занят игроком).

Псевдо-рандом.

В игре зашит массив из рандомных значений от 0 до 4, всего 128 элементов. На основе этого массива строится игровое поле. Для того чтобы игровое поле было разным, я смещаюсь по этому массиву на некоторую величину. Эта величина делает +1 каждый раз, когда находится в состоянии сброса. Таким образом чисел вроде не много, всего 128, и вроде бы предварительно взяты из функции рандома на ПК, но позиция в этом массиве по сути своей случайна и зависит от того, как нажимаете кнопку сброса.

 Параметры модуля game.sv

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

BG_COLOR – цвет фона, этим цветом отрисовывается все, что не активно и не попадает под условие отрисовки. Настоятельно советую его выбирать отличным от других цветов.

COLOR_1..5 – это цвета квадратиков на игровом поле, всего их 5, значения по умолчанию брал такими чтобы можно было адаптировать под RGB111, взяв лишь старший бит.

Выглядит это так:
Поиграем на ПЛИС? - 6

COLOR_TRACK_BAR – цвет незаполненной части трекбара.

COLOR_TRACK_BAR_PC – цвет трекбара для ПЛИС, либо второго игрока.

COLOR_TRACK_BAR_USER – цвет трекбара для первого игрока.

ANTI_BOUNCE_DELAY – параметр позволяет отработать дребезг кнопки. Сначала детектируется нажатие одной из кнопок затем срабатывает пауза, в пределах которой пользователь должен отпустить кнопку. Значение по-умолчанию подразумевает, что пользователь сделает одно короткое нажатие. Если у вас трясутся руки(например после праздников), либо кнопки сильно дребезжат то смело можете увеличивать это значение, исходя из того, что счетчик считает на частоте 25МГц.

DRAW_MARK – при установке в «1» отрисовывает точки в центре цветных квадратов, на игровом поле, это полезно в том случае, когда хочется самостоятельно посчитать число захваченных квадратиков.

Выглядит это так:
Поиграем на ПЛИС? - 7

NEW_YEAR – елки) просто переберите значения 0,1,2 и посмотрите на результат.

ENABLE_SIMPLE_AI – включение простого искусственного интеллекта [9], даже не интеллекта, а алгоритма поиска. Я провел несколько игр и понял, что если самому в ответ не считать квадратики, то с включенным режимом легко проиграть, поэтому если даете ребенку, то установите его в ноль. Сам алгоритм я описал выше в статье.

ONLY_GAME_MODE_0 – установив в «1» включается только один режим игры. Режим одиночного игрока. Этот режим нужен в том случае когда ПЛИС достаточно маленькая, и при включенном данном режиме отключается вся логика связанная с участием других игроков, таким образом проект собирается в 5к лутов(на artix 7).

INDICATE_WHO_STEP – показывает того кто ходит. Полезная опция если играет два игрока, если в качестве второго игрока выступает ПЛИС, то для пользователя будет казаться всегда ходит он, ПЛИС делает ход слишком быстро. Поэтому с точки зрения [10] экономии ресурсов этот параметр можно занулить.

Как запустить у себя?

Для тех, у кого имеется плата Nexys A7 достаточно скачать проект(https://github.com/mifa1234/fpga_color_flood [4]) и открыть его в Vivado, все должно работать из коробки. Для тех, кто имеет другую отладку, вам необходимо взять файл game.sv [11] из папки nexys_game_color_flood/nexys_game_color_flood.srcs/sources_1/new, и реализовать для него обертку, в котором реализуете следующее:

  1. подключение VGA интерфейса к своему видео выходу, если это HDMI, то с использованием преобразователя, например такого как в Vivado, rgb2dvi. Сигналы: vga_r, vga_g, vga_b, vga_v_sync, vga_h_sync, и сигнал vga_pixel_valid, если делаете преобразование через rgb2dvi.

  2. подачу тактового сигнала равного 25 МГц, в идеале, конечно, 25.175МГц чтобы соответствовало требуемому значению частоты пиксельного клока экрана с форматом 640х480 60Гц. Сигнал clk.

  3. подключение сигналов управления кнопки select и ok, для управления ходом. Активный уровень высокий, то есть, когда кнопка не нажата она выдает ноль. Если у вас кнопки другие, то достаточно сделать инверсию. Сигналы key_ok, key_select_up, key_select_down. Можно не подключать один из сигналов select, если не хватает кнопок или вам лень, выбор будет цикличным.

  4. подключить сигнал сброса, активный уровень низкий, то есть, когда кнопка не нажата, она возвращает единицу. Так же, как и с select и ok, вы можете просто инвертировать сигнал, чтобы добиться ожидаемого поведения [12] сигнала. Сигнал rstn_pb.

  5. Подключить значение mode_game, используется два бита, для него удобно использовать switch переключатели, то есть с фиксацией, но если у вас нет таких кнопок или вообще не хватает кнопок, то вы можете записать константу, в соответствии с режимом работы: 0 – одиночный режим, 1 – игра с ПЛИС, 2 – игра с другим игроком. Учтите что режим игры записывается в регистр только в состоянии сброса. Сигнал mode_game.

Кроме описанных выше сигналов есть выходные сигналы о статистике игры, их подключать не обязательно и можете использовать в любительских целях в рамках своего проекта.

  • result_game_each_step_valid  – устанавливается на один такт в «1» после каждого хода каждого игрока. Указывает об актуальности значений статистики ниже.

  • result_game_valid – устанавливается в «1» в конце игры. Указывает на актуальность значений статистики ниже.

Статистика:

  • count_steps_player_1  – число сделанных ходов первым игроком.

  • count_steps_player_2  – число сделанных ходов вторым игроком либо ПЛИС.

  • result_player_1 – число захваченных клеток первым игроком, максимальное значение 165.

  • result_player_2 – число захваченных клеток вторым игроком либо ПЛИС.

  • result_game_mode – режим игры при котором происходит игра.

Что вы можете изменить в проекте безопасно:

  • Менять параметры в пределах указанных значений

  • Изменить значение переменной color_rand_pos на любое, это позволит сделать смещение для массива рандомных значений цветов

  • Изменить инициализацию массива color_pre_rand_arr значениями от 0 до 4 включительно. Если вас по какой-то причине не устраивает случайно распределение, то можете сделать другое. Может быть даже закодировать туда рисунок.

  • Изменить инициализацию массива image_array, в нем хранится картинка размером 32х32 пикселя. Именно в ней хранится та самая предновогодняя ёлочка.

Кстати, а вот и код на питоне для конвертирования картинки в код инициализации:

Примерно после 88 строки кода:

В переменную input_image указываете путь до картинки размером 32х32. Возможно понадобится полный путь.

В переменную output_sv указываете путь, где должен сгенерироваться файл, вместе с названием файла.

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

from PIL import Image
import numpy as np

def convert_to_rgb444(r, g, b):
    """
    Конвертирует RGB888 в RGB444
    Вход: 8-битные значения R, G, B (0-255)
    Выход: 12-битное значение в формате RGB444
    """
    # Берем старшие 4 бита каждого канала
    r4 = (r >> 4) & 0x0F
    g4 = (g >> 4) & 0x0F
    b4 = (b >> 4) & 0x0F
    
    # Объединяем в 12-битное значение: R(4 бита) | G(4 бита) | B(4 бита)
    return (r4 << 8) | (g4 << 4) | b4

def image_to_sv_array(image_path, output_path):
    """
    Конвертирует изображение в SystemVerilog инициализацию массива
    
    Args:
        image_path: путь к входному изображению
        output_path: путь к выходному файлу SystemVerilog
    """
    try:
        # Открываем изображение
        img = Image.open(image_path)
        
        # Конвертируем в RGB формат если нужно
        if img.mode != 'RGB':
            img = img.convert('RGB')
        
        # Получаем размеры изображения
        width, height = img.size
        print(f"Размер изображения: {width}x{height} пикселей")
        
        # Создаем массив для хранения значений RGB444
        # Массив будет иметь размер [height][width]
        rgb444_array = np.zeros((height, width), dtype=np.uint16)
        
        # Загружаем пиксели изображения
        pixels = img.load()
        
        # Заполняем массив в правильном порядке:
        # Внешний цикл - по строкам (y, высота)
        # Внутренний цикл - по столбцам (x, ширина)
        for y in range(height):
            for x in range(width):
                # Получаем пиксель в координатах (x, y)
                r, g, b = pixels[x, y]
                # Записываем в массив [строка][столбец] = [y][x]
                rgb444_array[y, x] = convert_to_rgb444(r, g, b)
        
        # Генерируем SystemVerilog код
        with open(output_path, 'w') as f:
            f.write(f"// Автоматически сгенерированный файл инициализации изображенияn")
            f.write(f"// Исходное изображение: {image_path}n")
            f.write(f"// Размер: {width}x{height} пикселейn")
            f.write(f"// Формат цвета: RGB444 (12 бит на пиксель)n")
            f.write(f"// Массив объявлен как: image_array [height-1:0][width-1:0]n")
            f.write(f"// Порядок инициализации: для каждой строки (y) все столбцы (x)nn")
            
            # Объявление массива: [height-1:0][width-1:0]
            f.write(f"reg [11:0] image_array [{height-1}:0][{width-1}:0];nn")
            
            # Блок initial с инициализацией
            f.write("initial beginn")
            f.write("    // Инициализация построчно: сначала строка 0 (все столбцы), затем строка 1, и т.д.n")
            
            # Инициализация по строкам: сначала все столбцы для строки 0, потом для строки 1 и т.д.
            for y in range(height):
                f.write(f"n    // Строка {y}n")
                for x in range(width):
                    value = rgb444_array[y, x]
                    # image_array[строка][столбец] = image_array[y][x]
                    f.write(f"    image_array[{y}][{x}] = 12'h{value:03X};n")
            
            f.write("endn")
        
        print(f"Файл успешно создан: {output_path}")
        print(f"Пример значения для левого верхнего угла (0,0): image_array[0][0] = 12'h{rgb444_array[0, 0]:03X};")
        print(f"Пример значения для правого нижнего угла ({height-1},{width-1}): image_array[{height-1}][{width-1}] = 12'h{rgb444_array[height-1, width-1]:03X};")
        
    except Exception as e:
        print(f"Ошибка при обработке изображения: {e}")

# Пример использования
if __name__ == "__main__":
    # Укажите пути к вашим файлам
    input_image = "el2.png"  # Замените на путь к вашему изображению
    output_sv = "image_init.sv"  # Имя выходного файла
    
    image_to_sv_array(input_image, output_sv)
    
    print("nСоветы по использованию:")
    print("1. Убедитесь, что установлены необходимые библиотеки:")
    print("   pip install pillow numpy")
    print("2. Для больших изображений файл инициализации может быть очень большим")
    print("3. В SystemVerilog подключите этот файл с помощью:")
    print("   `include "image_init.sv"")
    print("4. Порядок индексов в массиве: image_array[y][x], где:")
    print("   - y: индекс строки (0 - верхняя строка, height-1 - нижняя строка)")
    print("   - x: индекс столбца (0 - левый столбец, width-1 - правый столбец)")

Заключение

Надеюсь, после данной статьи выражение «играю с ПЛИС» получит еще одно значение.

Призываю всех хоть раз запустить и дать обратную связь, что все получилось, так же указать потребление ресурсов на вашей плис.

Мое потребление ресурсов ПЛИС при полнофункциональной сборке :

уложился в 11к лутов

уложился в 11к лутов

Напомню ссылку на гитхаб: https://github.com/mifa1234/fpga_color_flood [4]

Автор: yamifa_1234

Источник [13]


Сайт-источник BrainTools: https://www.braintools.ru

Путь до страницы источника: https://www.braintools.ru/article/23447

URLs in this post:

[1] VGA: https://habr.com/ru/articles/736812/

[2] SDRAM: https://habr.com/ru/articles/737384/

[3] https://youtube.com/shorts/NrknB0c329g: https://youtube.com/shorts/NrknB0c329g

[4] https://github.com/mifa1234/fpga_color_flood: https://github.com/mifa1234/fpga_color_flood

[5] памяти: http://www.braintools.ru/article/4140

[6] случайности: http://www.braintools.ru/article/6560

[7] логику: http://www.braintools.ru/article/7640

[8] внимание: http://www.braintools.ru/article/7595

[9] интеллекта: http://www.braintools.ru/article/7605

[10] зрения: http://www.braintools.ru/article/6238

[11] game.sv: https://github.com/mifa1234/fpga_color_flood/blob/main/nexys_game_color_flood/nexys_game_color_flood.srcs/sources_1/new/game.sv

[12] поведения: http://www.braintools.ru/article/9372

[13] Источник: https://habr.com/ru/articles/978794/?utm_source=habrahabr&utm_medium=rss&utm_campaign=978794

www.BrainTools.ru

Rambler's Top100