Демонстрация
Я написал игру для Game Boy Color, которая рендерит изображения в реальном времени. Игрок управляет источником света и вращает объект.
Поиграть в неё можно здесь: https://blog.otterstack.com/posts/202512-gbshader/data/teapot.html
Посмотреть код и скачать ROM можно здесь: https://github.com/nukep/gbshader
Работа с 3D
Ранние этапы
Прежде, чем приступить к этому проекту, я поэкспериментировал в Blender, чтобы проверить, будет ли он вообще красиво выглядеть. Мне показалось, что да, поэтому я взялся за работу!
Я экспериментировал с «псевдодизерингом» на голове обезьяны в Blender, добавляя к каждой нормали небольшой случайный вектор.



От Blender к работе над картой нормалей
На самом деле, не важно, какое ПО я использовал для создания карт нормалей. Для меня Blender был путём наименьшего сопротивления, поэтому я выбрал его.
Я просто поместил в сцену чайник, покрутил вокруг него камеру и экспортировал AOV нормалей в виде последовательности PNG. Ничего сложного.
Я хотел, чтобы у вращающегося Game Boy Color определённые цвета были сплошными, поэтому использовал в Compositor cryptomattes, чтобы определить конкретную геометрию и вывести жёстко прописанные значения.
Геометрия на экране была реализована рендерингом отдельной сцены с её последующим композитингом в готовый рендер при помощи cryptomatte в качестве экрана.

Математика
Карты нормалей


Карты нормалей — базовая концепция этого проекта. Они активно применяются в 3D-графике.
Изображения карт нормалей на самом деле представляют собой поле векторов. Карты нормалей обычно синеватые, потому что XYZ обычно связывают с RGB, а +Z — это направленный вперёд вектор.
В типичном процессе работы с 3D карта нормалей используется для кодирования вектора нормали любой точки текстурированного меша.
Вычисление шейдера Ламберта при помощи скалярных произведений
Простейший способ затенения 3D-объекта — это применение скалярных произведений:
где N — вектор нормали, а L — позиция света, когда он направлен на точку начала координат (или эквивалентно: отрицательное направление света).
Разложив формулу по компонентам, получим:
Когда световой вектор постоянен для всех пикселей, он моделирует то, что в большинстве 3D-редакторов называется «удалённым источником» или «солнечным светом».
Сферические координаты
Для ускорения вычислений в Game Boy я использую альтернативную версию скалярного произведения, задействуя сферические координаты.
Сферическая координата — это точка, описываемая радиусом r, зенитным углом θ (тета) и азимутальным углом φ (фи). Они представляются в виде кортежа: (r, θ, φ)
Скалярное произведение двух сферических координат:
Так как все векторы нормалей имеют единичную длину, и световой вектор тоже имеет единичную длину, мы просто считаем радиус r равным 1. Тогда можно упростить произведение:
Заменив имена переменных, получим формулу:
Как сделать, чтобы всё это работало на Game Boy
Кодирование карт нормалей в Game Boy ROM
Из соображений производительности я решил присвоить постоянное значение. Игрок может управлять
, создавая эффект движущегося по орбите источника освещения.
Благодаря этому мы можем выделить постоянные коэффициенты m и b и переписать формулу:
ROM кодирует каждый пиксель в виде 3-байтного кортежа (Nφ,log(m),b).
Почему log(m)?.
В Game Boy нет команды умножения
CPU SM83 не поддерживает не только умножение, но и числа с плавающей запятой. Это очень неудобно.
Приходится быть очень изобретательным, ведь математический фундамент этого проекта связан с перемножением нецелых чисел.
Что же делать? Использовать логарифмы и таблицы поиска!
У логарифмов есть удобное свойство: можно выносить произведения за log. Благодаря этому мы можем складывать значения!
Для этого понадобятся две таблицы поиска: log и pow.
В псевдокоде произведение 0,3 и 0,5 выглядит так:
pow = [ ... ] # Таблица поиска из 256 элементов # float_to_logspace() выполняется во время компиляции. Принимает значения от -1.0 до +1.0. # x и y - 8-битные значения в logspace x = float_to_logspace(0.3) y = float_to_logspace(0.5) result = pow[x + y]
Одно из ограничений такого решения заключается в том, что невозможно получить логарифм отрицательного числа; например, log(−1) не имеет вещественного решения.
Мы можем обойти эту проблему, закодировав бит «знака» в старшем бите значения log-space. При сложении двух значений log-space выполняется, по сути, XOR бита знака. Нам нужно просто проверять, что остальные биты не переполнятся в него. Это можно гарантировать, если остальные биты будут достаточно малыми.
Таблица поиска pow учитывает этот бит и возвращает в зависимости от него положительный или отрицательный результат.
Все скалярные значения и табличные значения — 8-битные дроби
С точки зрения производительности в среде исполнения и размера ROM лучше ограничить числа одним байтом. По современным стандартам 8-битные дроби слишком неточны, но верьте или нет, их хватает. Теряется много информации, но их хватает!
Все скалярные значения, с которыми мы работаем, находятся в интервале от -1,0 до +1,0.
Байт | Значение в linear-space | Значение в log-space |
|---|---|---|
0 | ||
1 | ||
2 | ||
... | ||
126 | ||
127 | ||
128 | undefined | |
129 | ||
130 | ||
... | ||
254 | ||
255 |
И при сложении, и при умножении используется... сложение!
Возьмём для примера сложение двух байтов: 5 + 10 = 15
При сложении используются значения linear-space:
При умножении используются значения log-space:
Почему делитель равен 127, а не 128? Потому что мне нужно было представить и положительную, и отрицательную единицы. В кодировании дополнительным кодом знакового положительного 128 не существует.
Можно заметить, что значения log-space зацикливаются и становятся отрицательными в байте 128. Значения log-space используют бит 7 байта для кодирования бита «знака». Как говорилось в предыдущем разделе, это важно для смены знака при умножении.
Кроме того, значения log-space используют в качестве основания , потому что оно достаточно мало, чтобы при сложении трёх значений log-space не происходило переполнения (42+42+42 = 126). Байты с 43 по 127 близки к 0, поэтому на практике ROM не кодирует эти значения.
Таблицы поиска выглядят так:

Где:
encode(y) получает вещественное число и возвращает беззнаковый байт.
decode(x) получает беззнаковый байт и возвращает нужное число. Кроме того:
encode(y)=
decode(x)=
Воссозданные функции выглядят следующим образом. Погрешность точности проявляется в виде ломанных «лесенок»:

Может показаться, что погрешность велика, зато всё работает быстро и выглядит вполне неплохо!
Что такое cos_log?
По сути, это скомбинированное log(cosx). Она используется, потому что на практике косинус всегда применяется с умножением.
Базовая формула шейдера выглядит так:
Мы можем переписать её в следующем виде:
Здесь для каждого пикселя выполняются:
1 вычитание
1 одна операция поиска для
cos_log1 сложение
1 одна операция поиска для
pow1 сложение
Итого на каждый пиксель:
3 сложений/вычитаний
2 операции поиска
Насколько быстр код?
Процедура обрабатывает по 15 тайлов на кадр. Она может обрабатывать больше, если часть строк тайла пуста (все 0), но гарантированно обрабатывает не меньше 15.

Также присутствует преднамеренный визуальный тиринг («разрыв» картинки). Само изображение больше, чем 15 тайлов, поэтому в каждом кадре ROM переключается на рендеринг разных частей изображения. Тиринг менее заметен из-за смазывания на ЖК-дисплее, поэтому я решил, что он вполне приемлем.
Пиксель занимает примерно 130 тактов, а пиксель пустой строки — примерно 3 такта.
На одном из этапов разработки я вычислил, что 15 тайлов рендерятся ровно 123972 тактов, включая оверхед вызовов и ветвления. Сейчас этот показатель ниже, потому что я добавил оптимизацию для пустых строк.
CPU Game Boy Color работает с частотой до 8,388608 МГц, или приблизительно 139810 тактов на кадр (1/60 секунды).
Примерно 89% от доступного времени CPU кадра тратится на 15 тайлов на кадр. В оставшееся время выполняются другие функции, например, реагирование на пользовательский ввод и выполнение аппаратного ввода-вывода.
Самомодифицирующийся код

sub a, 0 на sub a, 8.Базовая подпрограмма шейдера содержит горячий путь исполнения, обрабатывающий около 960 пикселей на кадр. Очень важно, чтобы она была максимально быстрой!
Самомодифицирующийся код — крайне эффективный способ сделать код быстрым. Но большинство современных разработчиков его не пишет, и на то есть веские причины: он сложен, редко портируем, и его трудно реализовать, не внеся при этом серьёзных уязвимостей безопасности. Современные разработчики разбалованы избытком вычислительных мощностей, выбирающими оптимальные пути суперскалярными процессорами и современными средами исполнения JIT (Just-In-Time), которые генерируют код на лету. Но мы работаем с Game Boy, поэтому ничего этого у нас нет.
Если вы пишете на языках высокого уровня наподобие Python и JavaScript, то ближайшей аналогией самомодифицирующегося кода для вас может быть eval(). Вспомните, как вас заставляет нервничать eval(). Почти такое же чувство ощущают нативные разработчики по отношению к модифицирующимся командам.
На процессоре SM83 Game Boy быстрее будет складывать и вычитать жёстко прописанное число, чем загружать это число из памяти.
Например, x += 5 быстрее, чем x += variable.
unsigned char Ltheta = 8; // Медленнее v = (*in++) - Ltheta; // Быстрее v = (*in++) - 8;
На языке ассемблера SM83 это выглядит так:
; Медленнее: 28 тактов ld a, [Ltheta] ; 12 тактов: считывание переменной "Ltheta" из HRAM ld b, a ; 4 такта: копирование значения в регистр B ld a, [hl+] ; 8 тактов: считывание переменной из указателя HL sub a, b ; 4 такта: A = A - B ; Быстрее: 16 тактов ld a, [hl+] ; 8 тактов: считывание из указателя HL sub a, 8 ; 8 тактов: A = A - 8
Благодаря быстрому способу экономится 12 тактов. Если мы рендерим 960 пикселей, то суммарно это экономит нам 11520 тактов. Кажется, что это немного, но это примерно 10% от всего времени исполнения шейдера!
Неудачная попытка использования ИИ
«Через три-шесть месяцев ИИ будет писать 90% кода»
— Дарио Амодеи, CEO Anthropic (март 2025 года, на момент написания статьи прошло девять месяцев)
95% этого проекта написано вручную. Большие языковые модели с трудом пишут на языке ассемблера Game Boy. И я их не виню.
Краткое примечание
Я попробовал ИИ, чтобы протестировать процесс. Основные причины: 1) наша отрасль постоянно говорит об ИИ, 2) мне нужно было обоснованное мнение о нём при использовании в новых проектах, чтобы у меня были конкретный личный опыт. В конечном итоге, это всё-таки хобби-проект, поэтому смысл здесь не в ИИ! И тем не менее...
Я думаю, что нужно рассказывать о всех попытках или реальном использовании результатов работы генеративного ИИ, потому что считаю неэтичным обманывать людей о процессе своей работы. Так мы подрываем доверие и вносим свой вклад в дезинформацию или плагиат. Кроме того, раскрытие информации позволяет людям с разногласиями участвовать в работе. Кстати, я открыт к отзывам.
Вероятно, в будущем я напишу о своём опыте работы с ИИ.
Итак, раскрою, для чего же я использовал ИИ:
Python: чтение слоёв OpenEXR в скрипте конверсии для чтения данных карты нормалей
Python/Blender: скрипты Python для генерации сцен Blender с целью демонстрации процесса в Blender
Ассемблерный код SM83: фрагменты кода для использования таких фич Game Boy Color, как удвоенная скорость и VRAM DMA. Неудивительно, поскольку они, скорее всего, выложены где-то ещё.
Я неудачно пытался применять ИИ для:
Ассемблерного кода SM83: (не использовано) генерация первой версии кода шейдера
Также я решил рассказать, для чего я НЕ использовал ИИ:
Написание этой статьи
Алгоритмы, таблицы поиска, весь остальной ассемблерный код SM83
3D-ресурсы
Душа проекта 🌟 (наверно, все технобро сейчас закатили глаза)
Я попробовал заставить ИИ писать ассемблерный код Game Boy
Просто для того, чтобы проверить его возможности, я скинул псевдокод в Claude Sonnet 4 (заявляется, что это лучшая ИИ-модель для кодинга в 2025 году) и попросил его сгенерировать ассемблерный код SM83:
https://claude.ai/share/846cb7d4-e4a6-40ab-8aaa-6e4c308e3da3
Это был интересный процесс. Для начала я сам проделал работу Claude, дав ему псевдокод, потому что уже придумал формат данных и предполагал, что у него возникнут трудности с высокоуровневым описанием.
Изначально я был настроен скептически, но он справился лучше, чем я думал. После моих инструкций он даже создал код, который запускался. Однако он был не очень быстрым, и Claude поначалу допустил несколько ошибок, спутав процессор SM83 с Z80. Я попытался заставить Claude оптимизировать код при помощи рекомендаций; сначала он справлялся неплохо, но добавлял ошибки, а потом у меня кончился лимит беседы.
После этого я всё переписал вручную. Моя реализация агрессивно оптимизирована и почти ничем не похожа на версию Claude.
И ещё он обожает говорить мне, что я «абсолютно прав».

Он лучше подходил для маленьких задач и фрагментов кода. Скрипт тайлового демо из моего видео частично написан ИИ. Подпрограмма Game Boy для копирования в VRAM была создана ИИ, хоть столь же легко её можно найти в Интернете.
Одна из первых итераций скрипта конверсии карты нормалей принимала файлы OpenEXR. Мне не хотелось возиться с новой библиотекой, поэтому я попросил ChatGPT преобразовать файл OpenEXR в массив numpy. И он справился довольно неплохо! Однако при этом он добавил очень малозаметный баг, который я не мог отловить в течение нескольких недель. Когда я наконец-то прочитал код, то осознал, что он сортировал имена каналов в алфавитном порядке (поэтому XYZ сортировалось, как XYZ, но RGB сортировалось, как BGR). Сам бы я никогда не допустил такой ошибки.
Обновление: оказалось, что код OpenEXR всё это время можно было реализовать в двух строках. В одном из первых примеров в официальном readme PyPi показано, как получить массив numpy из файла OpenEXR; именно это мне и было нужно. Теоретически, я мог бы дополнить этот код разными каналами, но по сути, это всё. ChatGPT выдал мне тридцать строк для обработки пограничных случаев, которые просто никогда не возникали.
with OpenEXR.File("readme.exr") as infile: RGB = infile.channels()["RGB"].pixels
Вот поэтому крайне важна верифицируемость.
Этот и другие случаи заставили меня осознать, насколько легко потерять бдительность при подобном применении ИИ, даже если ты опытный кодер. ИИ может быть полезным, но разработчику требуется осмотрительность. К счастью, я не доверял ИИ в установке пакетов-галлюцинаций.
