Железо-ограничения: спрайты, тайлы, память, fixed-point
раз×2¹⁶, считаем целочисленно, потому что float'а в железе нет). Это не археология: тайлы → атласы текстур, fixed-point → квантизация нейросетей, палитра → таблицы поиска.
Механизм
Три ограничения и три ответа на них. Все три — про одно: не храни сырое, храни ссылку и переиспользуй.
Стена памяти и «гонка за лучом»
У Atari 2600 — 128 байт RAM и нет кадрового буфера. Картинку нельзя «нарисовать в память и показать»: её негде держать. Поэтому процессор гонится за лучом (racing the beam): луч кинескопа физически бежит по экрану слева направо, строка за строкой сверху вниз, ~60 раз в секунду, и CPU обязан выставить нужные регистры видеочипа TIA для строки за такты до того, как луч до неё дойдёт. На строку — всего ~76 тактов; код всё время держится чуть впереди луча — отсюда и «гонка»: отстал на такт, и строка нарисуется мусором. Изображение существует только как поток во времени, не как массив пикселей. NES (1983) добавляет 2 КБ RAM и отдельный графический процессор PPU с аппаратными тайлами и спрайтами — и это скромное расширение открывает целый класс игр (скроллинг, большие миры).
Тайловая карта — экран как сетка индексов
Хранить экран попиксельно дорого. Полноэкранный буфер Pac-Man (224×288 px по байту на пиксель) — это
— в 1980-м столько RAM просто нет. Решение: разбить экран на сетку тайлов 8×8 px; в памяти держать не пиксели, а индекс плитки в каждой клетке. Сами рисунки плиток лежат в ROM один раз и переиспользуются.
Самый точный аналог для программиста — это рендеринг текста: терминал не хранит картинку страницы, он держит сетку кодов символов (A=65, B=66…), а формы букв (шрифт) лежат отдельно, один раз. Тайл — ровно то же самое, только «шрифт» это не буквы, а кусочки графики (стена, точка, угол). Старое железо так и звало тайлы «characters», а их память — CHR-ROM: тот же механизм, что текстовый дисплей, просто штампы — это графика.
Карта NES — это nametable на 32×30 тайлов:
Бонус: скроллинг «бесплатный» — сдвинул индексы и дорисовал один новый столбец тайлов вместо перерисовки всего экрана. Минус — мир выглядит «клетчатым» из переиспользуемых блоков, и это узнаваемая эстетика всей 8-битной эпохи.
Спрайты — подвижные объекты поверх фона
То, что движется (игрок, враги, пули), — не часть тайловой карты, а спрайты: маленькие битмапы 8×8 или 16×16, которые видеочип компонует поверх фона аппаратно. Название — от английского sprite (фея, дух): такие картинки будто «парят» над фоном независимо от него, как духи над сценой. У NES OAM (object attribute memory) — это 256 байт = 64 спрайта по 4 байта (y, номер плитки, атрибуты, x). Но есть жёсткий лимит: не больше 8 спрайтов на одну строку развёртки (вторичный OAM держит ровно 8). Девятый и дальше на этой строке не рисуются. Чтобы не терять объекты насовсем, игры тасуют приоритет спрайтов каждый кадр — и вместо «пропал» получается знакомое мерцание (фликер) в плотных сценах: каждый спрайт виден через кадр.
Fixed-point — дроби без FPU
У 6502 и раннего 68000 нет аппаратной плавающей точки, а часто и умножения. Но физике нужны дробные скорости. Трюк: хранить число как целое, домноженное на степень двойки. Формат Q16.16 — 16 бит на целую часть, 16 на дробную; реальное значение из «сырого» целого r:
Сложение и вычитание — обычные целочисленные (1 такт против 20+ за эмуляцию float). Целую часть (пиксель на экране) достаём сдвигом:
Умножение — единственная тонкость: после перемножения двух Q16.16-чисел дробные доли складываются (32 бита), и результат надо сдвинуть назад на 16, держа промежуток в широком регистре, чтобы не потерять старшие биты:
Числовой пример. Скорость 0.5 px/тик в Q16.16 — это r = 0.5 · 65536 = 32768. Пять тиков: posFixed = 5 · 32768 = 163840; пиксель = 163840 >> 16 = 2 (точно 2.5 px, но на экране — целые 2; дробь 0.5 копится и на 6-м тике даст 3-й пиксель). Float не понадобился — только сложение и сдвиг.
🕹 В какие игры поиграть — и что заметить
Одни и те же три приёма — тайлы, спрайты, фликер — видны руками на всех машинах эпохи, от «гонки за лучом» до PPU. По каждому кейсу: как сделано и что включить/посчитать, чтобы увидеть ограничение. От «памяти нет вообще» к «памяти чуть больше — и вот новый класс игр».
128 байт RAM, картинку рисует CPU «на лету» по ходу луча. Аппаратно всего пара спрайтов (игроки) + пара «снарядов» + «мяч» + фон-плейфилд. Больше двух объектов в ряд — уже фокус с перерисовкой регистров. Adventure прячет знаменитую первую «пасхалку» там, где железо вообще не должно было позволить лишний объект.
🎮 Сыграй: запусти Adventure или Combat в Stella. Посчитай, сколько объектов одновременно на экране — почти всегда мало, и они мерцают, когда их «слишком много» в одной строке. Это потолок «двух спрайтов» из-за гонки за лучом.
Лабиринт — чистая тайловая карта: сетка ~28×31, в каждой клетке индекс плитки (стена / точка / пусто). Сам лабиринт неподвижен и живёт в ROM; в RAM меняется в основном состояние точек (съедена/нет) — это уже почти битмап в десятки байт. Призраки и Pac-Man — спрайты поверх карты.
🎮 Сыграй: в любом порте Pac-Man посмотри на лабиринт как на сетку: каждая «клетка» — один тайл. Стены повторяются — это одна и та же плитка с разным номером в карте, а не уникальный рисунок.
Огромные уровни помещаются в картридж, потому что мир — это тайлы из общего набора (кирпич, труба, облако), а скроллинг сдвигает nametable и дорисовывает один столбец. Облако и куст — одна плитка с разной палитрой. Марио и Луиджи — тот же спрайт, другая палитра. Переиспользование везде.
🎮 Сыграй: в SMB заметь, что куст и облако имеют одинаковую форму — это буквально одна плитка, перекрашенная палитрой. Враги-«палитросвопы» (красный/серый Купа) — тот же приём ради экономии ROM.
Когда в одной горизонтальной полосе оказывается больше 8 спрайтов (босс + снаряды + игрок), PPU физически не вытягивает девятый — игра тасует приоритет, и объекты мерцают. Это не баг рендера, а прямое следствие вторичного OAM на 8 записей.
🎮 Сыграй: в Mega Man (или Contra) выйди в сцену с боссом и кучей пуль на одной высоте — увидишь характерное мерцание спрайтов. Это лимит «8 на строку» глазами.
Хардкор · теория: точность, диапазон и переполнение fixed-pointможно пропустить
Формат Qm.n делит m+n бит на целую и дробную части. Это решает два параметра разом:
Диапазон против точности — одной ручкой
Шаг (наименьшее представимое) и максимум жёстко связаны: при n дробных битах
Сдвинул точку вправо (больше n) — точнее дроби, но меньше потолок до переполнения. Q16.16 — компромисс «±32768 с шагом 1/65536». Сравни с float: тот двигает точку (экспонента) и потому даёт огромный динамический диапазон, но переменную абсолютную точность; fixed-point — постоянный абсолютный шаг, что для геймплейной физики даже удобнее (детерминизм, нет дрейфа ULP).
Умножение и переполнение
Произведение двух Q16.16 имеет 32 дробных бита и до 32 целых — нужен 64-битный (или аккуратный 32-битный) промежуток, иначе старшие биты срежутся. Деление — наоборот, сначала сдвиг влево на n, потом целочисленное деление. На 6502 даже целого умножения нет — его делали сложением и таблицами квадратов (a·b = ((a+b)² − (a−b)²)/4 по предпосчитанной таблице x²).
Почему вообще степень двойки
Масштаб 2ⁿ превращает деление/умножение на масштаб в битовый сдвиг — один такт. Любой другой масштаб (например, ×1000) потребовал бы настоящего деления. Та же причина, по которой выравнивания и размеры буферов берут степенями двойки.
Хардкор · инженерия: где эти приёмы живут в современном движкеможно пропустить
- Тайл → атлас текстур. Современный 2D-рендер бьёт спрайты в один texture atlas и рисует батчем (один draw call на сотни плиток) — ровно ради того же: переиспользовать память и не дёргать GPU на каждый объект. Tilemap-движки (Tiled, Godot TileMap) — прямые потомки nametable.
- Спрайт-компоновка → композитинг. Аппаратные спрайты поверх фона = ранний hardware compositing; сегодня это слои/quads на GPU и оконные композиторы ОС.
- Палитра → indexed color / LUT. Индекс в палитру — это таблица поиска (LUT). Палитросвоп = смена одной таблицы вместо перерисовки. LUT'ы живут в цветокоррекции (color grading), тонмаппинге, шейдерах.
- Fixed-point → детерминированная и квантованная арифметика. Лок-степ-RTS и rollback-файтинги до сих пор берут fixed-point, потому что float не воспроизводим между платформами. А квантизация нейросетей (int8/int4) — это тот же Q-формат с масштабом и нулевой точкой.
- Гонка за лучом → beam racing сегодня. Идея «синхронизируйся с лучом, не буферизуй» вернулась в low-latency рендере (scanline-синхронный вывод, VRR, «frontbuffer»-трюки) ради минимальной задержки.
ML / AI (твой домен): fixed-point Q-формат — это квантизация: int8/int4-инференс хранит веса как целые с масштабом и zero-point, ровно как v = r·2⁻ⁿ; выбор n = выбор «range vs precision» при калибровке. Палитра/индекс ⇄ codebook в VQ-VAE и embedding-lookup (индекс → вектор из таблицы). Переиспользование плиток ⇄ weight sharing (свёртки, tied embeddings). Блочное кодирование тайлов ⇄ патчи в ViT. Но держи границу: аналогия «тайл = токен» тугая только для дискретного представления (BPE-токены, индексы VQ-VAE); для непрерывного латента (обычный VAE, латент диффузии) её нет — там не словарь индексов, а непрерывная параметризация. Видеть, где аналогия держится, а где ломается, важнее самой аналогии.
Системы / данные: индекс-в-таблицу = dictionary encoding в колоночных БД (Parquet/Arrow) и LUT; атлас = упаковка ресурсов; «статика в ROM, динамика в RAM» = разделение read-only кэша и горячего состояния.
Графика / компрессия: тайлы = макроблоки в JPEG/видео (DCT по блокам), палитра = индексированный PNG/GIF; всё это «храни словарь, ссылайся индексом».
Принцип: когда ресурс дефицитен — не храни сырое. Заведи словарь часто повторяющегося, ссылайся индексом, а числа держи в наименьшем достаточном представлении.
dt воспроизводима побитово.Fixed-point — это же просто «целые, поделённые на масштаб». Зачем отдельное название?
2ⁿ (чтобы умножение/деление на масштаб стали сдвигом в 1 такт) и он общий для всех величин, иначе складывать нельзя. «Q16.16» сообщает и масштаб, и раскладку бит одним токеном. Тот же объект в ML зовут «квантизация с масштабом степени двойки» — суть идентична, меняется лишь словарь.Почему при >8 спрайтах железо мерцает, а не просто роняет лишние?
Тайлы дают «бесплатный скроллинг» — почему именно бесплатный?
Почему float был бы хуже, даже будь он в железе?
Если плитки лежат в ROM, что вообще держится в крошечной RAM?
- Nick Montfort & Ian Bogost, «Racing the Beam» (2009) — архитектура Atari 2600 и гонка за лучом, глубоко и читабельно.
- Michael Abrash, «Graphics Programming Black Book», ч. I «Fixed-Point Arithmetic» — бесплатно онлайн.
- NESdev Wiki — PPU, OAM, лимит 8 спрайтов на строку, nametable (первоисточник по железу NES).
- Модуль 1, «Hardware Constraints» + «Technical Study» (
01-foundations-1970-1985.md).