← Модуль 1/Железо-ограничения
EN
Модуль 1 · Аркады и основы

Железо-ограничения: спрайты, тайлы, память, fixed-point

Почему 128 байт RAM, отсутствие FPU и отсутствие кадрового буфера выковали вечные примитивы движка — тайлы, спрайты и арифметику с фиксированной точкой, — которыми ты пользуешься и сегодня.
~16 мин
Суть за 20 секунд
Железо 1970–80-х было нищим: Atari 2600 — 128 байт RAM, 6502 без умножения/деления, на дешёвых машинах нет даже кадрового буфера. Из этого родились три приёма, которые пережили всё железо: тайловая карта (экран — сетка индексов в таблицу плиток, а не миллион пикселей → экономия памяти в десятки раз), спрайты (маленькие битмапы, которые железо компонует поверх фона) и 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 по байту на пиксель) — это

224×288=64512 байт≈63КБ

— в 1980-м столько RAM просто нет. Решение: разбить экран на сетку тайлов 8×8 px; в памяти держать не пиксели, а индекс плитки в каждой клетке. Сами рисунки плиток лежат в ROM один раз и переиспользуются.

Самый точный аналог для программиста — это рендеринг текста: терминал не хранит картинку страницы, он держит сетку кодов символов (A=65, B=66…), а формы букв (шрифт) лежат отдельно, один раз. Тайл — ровно то же самое, только «шрифт» это не буквы, а кусочки графики (стена, точка, угол). Старое железо так и звало тайлы «characters», а их память — CHR-ROM: тот же механизм, что текстовый дисплей, просто штампы — это графика.

Карта NES — это nametable на 32×30 тайлов:

32×30=960 байт против ≈63КБ (в ~67 раз меньше)

Бонус: скроллинг «бесплатный» — сдвинул индексы и дорисовал один новый столбец тайлов вместо перерисовки всего экрана. Минус — мир выглядит «клетчатым» из переиспользуемых блоков, и это узнаваемая эстетика всей 8-битной эпохи.

карта (индексы) 1 1 2 1 0 0 2 0 1 2 2 1 1 байт / клетку → таблица плиток (ROM) 0 1 2 0 — пусто · 1 — стена · 2 — точка рисунок плитки лежит раз, в карте — только её номер
Экран = маленькая таблица индексов + переиспользуемые плитки. Память — на карту, а не на пиксели.

Спрайты — подвижные объекты поверх фона

То, что движется (игрок, враги, пули), — не часть тайловой карты, а спрайты: маленькие битмапы 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:

vreal= r216 = r·2−16

Сложение и вычитание — обычные целочисленные (1 такт против 20+ за эмуляцию float). Целую часть (пиксель на экране) достаём сдвигом:

posFixed += velFixed; // шаг физики — целочисленное сложение screenX = posFixed >> 16; // целая часть = координата пикселя

Умножение — единственная тонкость: после перемножения двух Q16.16-чисел дробные доли складываются (32 бита), и результат надо сдвинуть назад на 16, держа промежуток в широком регистре, чтобы не потерять старшие биты:

c= (a·b) ≫16
целая · 16 бит дробная · 16 бит бит 31 бит 16 бит 0 screenX = r ≫ 16 точность ≈ 1/65536
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. По каждому кейсу: как сделано и что включить/посчитать, чтобы увидеть ограничение. От «памяти нет вообще» к «памяти чуть больше — и вот новый класс игр».

Atari 2600 · Combat / Adventure 1977–79 · гонка за лучом, кадрового буфера нет

128 байт RAM, картинку рисует CPU «на лету» по ходу луча. Аппаратно всего пара спрайтов (игроки) + пара «снарядов» + «мяч» + фон-плейфилд. Больше двух объектов в ряд — уже фокус с перерисовкой регистров. Adventure прячет знаменитую первую «пасхалку» там, где железо вообще не должно было позволить лишний объект.

🎮 Сыграй: запусти Adventure или Combat в Stella. Посчитай, сколько объектов одновременно на экране — почти всегда мало, и они мерцают, когда их «слишком много» в одной строке. Это потолок «двух спрайтов» из-за гонки за лучом.

Pac-Man 1980 · экран = тайловая карта

Лабиринт — чистая тайловая карта: сетка ~28×31, в каждой клетке индекс плитки (стена / точка / пусто). Сам лабиринт неподвижен и живёт в ROM; в RAM меняется в основном состояние точек (съедена/нет) — это уже почти битмап в десятки байт. Призраки и Pac-Man — спрайты поверх карты.

🎮 Сыграй: в любом порте Pac-Man посмотри на лабиринт как на сетку: каждая «клетка» — один тайл. Стены повторяются — это одна и та же плитка с разным номером в карте, а не уникальный рисунок.

Super Mario Bros 1985 · NES · скроллинг тайлами + палитры

Огромные уровни помещаются в картридж, потому что мир — это тайлы из общего набора (кирпич, труба, облако), а скроллинг сдвигает nametable и дорисовывает один столбец. Облако и куст — одна плитка с разной палитрой. Марио и Луиджи — тот же спрайт, другая палитра. Переиспользование везде.

🎮 Сыграй: в SMB заметь, что куст и облако имеют одинаковую форму — это буквально одна плитка, перекрашенная палитрой. Враги-«палитросвопы» (красный/серый Купа) — тот же приём ради экономии ROM.

Mega Man / поздний NES фликер из лимита 8 спрайтов на строку

Когда в одной горизонтальной полосе оказывается больше 8 спрайтов (босс + снаряды + игрок), PPU физически не вытягивает девятый — игра тасует приоритет, и объекты мерцают. Это не баг рендера, а прямое следствие вторичного OAM на 8 записей.

🎮 Сыграй: в Mega Man (или Contra) выйди в сцену с боссом и кучей пуль на одной высоте — увидишь характерное мерцание спрайтов. Это лимит «8 на строку» глазами.

Хардкор · теория: точность, диапазон и переполнение fixed-pointможно пропустить

Формат Qm.n делит m+n бит на целую и дробную части. Это решает два параметра разом:

Диапазон против точности — одной ручкой

Шаг (наименьшее представимое) и максимум жёстко связаны: при n дробных битах

шаг=2−n, xmax≈2m−1

Сдвинул точку вправо (больше 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»-трюки) ради минимальной задержки.
Аналогия
Тайловая карта — это конструктор LEGO по номерам: вместо того чтобы рисовать каждый пиксель стены, ты пишешь «клетка 5 — кирпич №1», а сам кирпич отлит один раз. Спрайты — фигурки, которые ставят поверх собранной доски и двигают отдельно. Fixed-point — линейка с фиксированными делениями: точка между целыми и дробными «всегда на одном месте», поэтому считать дёшево, но дальше последнего деления не уедешь.
Почему это важно
Эти три приёма — не музей. Когда у тебя снова мало ресурса — память GPU, пропускная способность шины, килобайты на embedded, биты на веса модели — ты переоткрываешь ровно их: переиспользование через индекс (тайл/атлас/палитра), отделение «фона» от «подвижного» (статика против динамики) и квантованную целочисленную арифметику вместо дорогого float. Ограничения 1980-го — это тренажёр инженерного мышления «дёшево и достаточно», и он прямо переносится на твой домен.
🔁 За пределами игр — куда это переносится
Урок даёт три переносимых приёма: индекс вместо данных (ссылка + переиспользование), разделение статики и динамики, и квантованная целочисленная арифметика с фиксированным масштабом.

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; всё это «храни словарь, ссылайся индексом».

Принцип: когда ресурс дефицитен — не храни сырое. Заведи словарь часто повторяющегося, ссылайся индексом, а числа держи в наименьшем достаточном представлении.

🔧 Запусти и поковыряй — на домашнем компе
Во что играть — выше (🕹). Здесь — залезть в железо эпохи через эмулятор с дебаггером:
🔧 Поковырять (debug) ~40 мин, Mesen/Stella
Открой NES-эмулятор Mesen (есть отладчик PPU). Загрузи Super Mario Bros, открой просмотр nametable и OAM/спрайты: увидишь экран как сетку индексов плиток и таблицу из 64 спрайтов вживую. Поставь брейкпоинт на запись в OAM — поймаешь момент, когда игра тасует приоритет спрайтов. В Stella (Atari 2600) включи отладчик и посмотри, как игра переписывает регистры TIA по строкам — гонка за лучом покадрово.
🧪 Потестить (глазами QA) ~15 мин
Ищи следы ограничений: мерцание при >8 спрайтах на строке (Mega Man, Contra), «палитросвоп»-врагов (один рисунок — разные цвета), повторяющиеся плитки в фоне, дрожание объектов на краю экрана. Отметь, где художник прячет ограничение (тёмный фон скрывает фликер), а где оно вылезает.
Чеклист: увидел nametable как сетку индексов; нашёл лимит 8 спрайтов глазами (фликер); опознал хотя бы один палитросвоп.
Связи
основа
Игровой цикл и fixed timestep — fixed-point и фиксированный шаг вместе дают детерминизм: целочисленная физика на постоянном dt воспроизводима побитово.
дальше
Коллизии: AABB и тайловые — спрайты и тайлы из этого урока — те самые «коробки» и «клетки», между которыми считают столкновения.
смежное
ECS и data-oriented — «индекс вместо данных» и плотные массивы — прямое продолжение мысли о раскладке памяти.
Вопросы пытливого ума
Fixed-point — это же просто «целые, поделённые на масштаб». Зачем отдельное название?
Да, по сути это и есть масштабированные целые — но название фиксирует конвенцию: масштаб всегда 2ⁿ (чтобы умножение/деление на масштаб стали сдвигом в 1 такт) и он общий для всех величин, иначе складывать нельзя. «Q16.16» сообщает и масштаб, и раскладку бит одним токеном. Тот же объект в ML зовут «квантизация с масштабом степени двойки» — суть идентична, меняется лишь словарь.
Почему при >8 спрайтах железо мерцает, а не просто роняет лишние?
Аппаратно девятый спрайт на строке и правда не рисуется — это жёсткий лимит вторичного OAM на 8 записей. Но если порядок спрайтов фиксирован, «лишний» пропадал бы всегда один и тот же. Игры каждый кадр тасуют приоритет (циклический сдвиг списка), поэтому «невидимым» по очереди оказывается то один объект, то другой — каждый виден через кадр. Глаз собирает это в полупрозрачное мерцание вместо «пропал». Деградация вместо отказа — сознательный инженерный выбор.
Тайлы дают «бесплатный скроллинг» — почему именно бесплатный?
Потому что при сдвиге камеры на тайл не меняется почти ничего: старые индексы остаются на местах (их просто читают со смещением), дорисовать надо один новый ряд/столбец плиток на въезжающем крае. Попиксельный буфер при том же сдвиге пришлось бы перерисовать целиком. Стоимость скролла падает с O(экран) до O(край). PPU NES делает это аппаратно через регистры прокрутки.
Почему float был бы хуже, даже будь он в железе?
Для динамического диапазона float лучше, но у геймплейной физики 80-х его требования скромные, зато критичны детерминизм и дешевизна. Float даёт переменную абсолютную точность (ULP растёт с величиной) и невоспроизводимость между платформами/компиляторами — яд для лок-степа и реплеев. Fixed-point — постоянный шаг, побитовая повторяемость, целочисленные операции. Поэтому даже сегодня детерминированные RTS/файтинги берут fixed-point намеренно, а не от бедности.
Если плитки лежат в ROM, что вообще держится в крошечной RAM?
Не картинки, а изменяемое состояние: где сейчас спрайты (OAM), какие точки съедены, счёт, таймеры, индексы прокрутки, текущая карта-ссылка. Рисунки плиток и спрайтов — read-only в ROM/CHR. Это и есть разделение «статика (ROM) / динамика (RAM)»: 2 КБ RAM хватает на состояние, потому что тяжёлые ассеты в неё не грузятся. Прямой предок современного деления на immutable-ассеты и горячий стейт.
Что почитать