Демонстрация
Я написал игру для 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
Вот поэтому крайне важна верифицируемость.
Этот и другие случаи заставили меня осознать, насколько легко потерять бдительность при подобном применении ИИ, даже если ты опытный кодер. ИИ может быть полезным, но разработчику требуется осмотрительность. К счастью, я не доверял ИИ в установке пакетов-галлюцинаций.