Спрайты
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Спрайты
Фон неподвижен. Всё, что бегает, прыгает и стреляет, это спрайты: отдельные тайлы, которые PPU кладёт поверх фона по своим координатам. У NES их ровно 64, и на одну строку видно максимум восемь. Эти ограничения не баг, а движок целой эпохи геймдизайна: мерцание спрайтов и трюки со скроллом растут отсюда. Урок продолжает PPU и фон и доводит картинку до полной.
Спрайты уже в файле
Новый файл сегодня мы не создаём. В прошлом уроке ты собрал src/ppu/mod.rs целиком, и спрайтовый код уже там: поля в структуре Ppu, методы evaluate_sprites, load_sprite_shifters, мультиплексор внутри render_pixel. Ты их набрал, теперь разберём, что именно ты напечатал, строка за строкой, чтобы каждая встала на место.
Все блоки кода ниже это куски того самого src/ppu/mod.rs. Открой файл рядом и сверяйся: мы идём по спрайтовой половине PPU сверху вниз, от раскладки OAM до того момента, когда фон и спрайт сливаются в один пиксель.
OAM: где живут спрайты
OAM это 256 байт, по 4 на спрайт. Заполняется почти всегда через DMA: запись номера страницы в $4014 копирует 256 байт из RAM в OAM за один присест. Эту DMA мы подключим к шине в финале блока, а пока важна раскладка одного спрайта. На время строки спрайт копируется из плоского массива oam в типизированную запись, она объявлена прямо под структурой Ppu:
/// Одна запись OAM: четыре байта на спрайт.
#[derive(Clone, Copy, Default)]
struct SpriteEntry {
y: u8,
tile: u8,
attr: u8,
x: u8,
/// Индекс спрайта в OAM (нужен, чтобы отследить спрайт 0).
index: u8,
}
Первые четыре поля это и есть те самые четыре байта OAM: координата y, номер тайла, байт атрибутов (в нём упакованы палитра, приоритет и флаги отражения) и координата x. Пятое поле, index, в железе нет: мы добавили его сами, чтобы помнить, какой по счёту спрайт в OAM попал в запись. Без него не отличить нулевой спрайт от прочих, а на нём держится главный трюк урока.
В структуре Ppu под спрайты выделена отдельная группа полей, ты её набирал в блоке // --- спрайты текущей сканлайн ---:
// --- спрайты текущей сканлайн ---
/// Отобранные на следующую строку спрайты (до восьми), копия их 4 байт OAM.
scanline_sprites: [SpriteEntry; 8],
sprite_count: usize,
sprite_shift_lo: [u8; 8],
sprite_shift_hi: [u8; 8],
sprite_zero_possible: bool,
sprite_zero_rendered: bool,
scanline_sprites это вторичный буфер на восемь спрайтов (аппаратный предел строки), sprite_count сколько их реально набралось. Пара sprite_shift_lo и sprite_shift_hi это сдвиговые регистры с битовыми планами паттерна для каждого из восьми, по байту на регистр. Два булевых флага про нулевой спрайт: sprite_zero_possible отвечает, попал ли спрайт 0 на эту строку при отборе, а sprite_zero_rendered загорается уже при рендере пикселя, когда непрозрачный пиксель именно нулевого спрайта реально вышел на экран. Их совпадение и даёт sprite 0 hit.
Sprite evaluation
На каждой видимой строке PPU заранее (в конце текущей строки, на точке 257) выбирает, какие из 64 спрайтов попадают на следующую строку, и складывает первые восемь во вторичный буфер. Это и есть sprite evaluation. Вот метод целиком, как он стоит в твоём mod.rs:
/// Отобрать до восьми спрайтов, попадающих на следующую строку.
fn evaluate_sprites(&mut self) {
self.scanline_sprites = [SpriteEntry::default(); 8];
self.sprite_count = 0;
self.sprite_zero_possible = false;
let height = self.sprite_height();
for n in 0..64 {
let base = n * 4;
let y = self.oam[base] as i16;
let row = self.scanline - y;
if row < 0 || row >= height as i16 {
continue;
}
if self.sprite_count == 8 {
self.status |= STATUS_OVERFLOW;
break;
}
if n == 0 {
self.sprite_zero_possible = true;
}
self.scanline_sprites[self.sprite_count] = SpriteEntry {
y: self.oam[base],
tile: self.oam[base + 1],
attr: self.oam[base + 2],
x: self.oam[base + 3],
index: n as u8,
};
self.sprite_count += 1;
}
}
Разбор по шагам. Сначала чистим буфер и счётчик, сбрасываем sprite_zero_possible. height это 8 или 16 в зависимости от режима спрайтов (его отдаёт sprite_height). Дальше проходим все 64 спрайта в порядке OAM. Для каждого читаем его y из плоского массива и считаем row, на какой строчке внутри спрайта мы оказались. Если row вне диапазона 0..height, спрайт на этой строке не виден, пропускаем.
Дальше два ключевых условия. Если уже набрали восемь, девятый и все следующие выпадают, и в статус взводится флаг переполнения STATUS_OVERFLOW. Отсюда классическое мерцание: игры нарочно сдвигают порядок спрайтов в OAM между кадрами, чтобы из строки выпадали по очереди разные, и глаз видит мигание вместо полной пропажи. Второе условие: если это спрайт с индексом 0, помечаем sprite_zero_possible, без этого флага не будет sprite 0 hit. Наконец копируем четыре байта OAM в SpriteEntry, дописываем index и увеличиваем счётчик.
Загрузка сдвиговых регистров
Сразу после отбора (на той же точке 257) PPU подгружает байты паттернов выбранных спрайтов в сдвиговые регистры, учитывая отражения. Метод:
/// Подгрузить байты паттернов отобранных спрайтов в сдвиговые регистры.
fn load_sprite_shifters(&mut self, mapper: &mut dyn Mapper) {
let height = self.sprite_height();
for i in 0..self.sprite_count {
let sprite = self.scanline_sprites[i];
let flip_v = sprite.attr & 0x80 != 0;
let flip_h = sprite.attr & 0x40 != 0;
let mut row = (self.scanline - sprite.y as i16) as u16;
if flip_v {
row = height - 1 - row;
}
let addr = if height == 16 {
// 8x16: таблица из бита 0 номера тайла, верхняя и нижняя половины.
let table = (sprite.tile as u16 & 0x01) << 12;
let tile = (sprite.tile as u16 & 0xFE) + if row >= 8 { 1 } else { 0 };
table | (tile << 4) | (row & 0x07)
} else {
let table = (self.ctrl & CTRL_SPRITE_TABLE) as u16; // 0x08
(table << 9) | ((sprite.tile as u16) << 4) | row
};
let mut lo = self.read_vram(addr, mapper);
let mut hi = self.read_vram(addr + 8, mapper);
if flip_h {
lo = lo.reverse_bits();
hi = hi.reverse_bits();
}
self.sprite_shift_lo[i] = lo;
self.sprite_shift_hi[i] = hi;
}
}
Для каждого отобранного спрайта читаем два флага отражения из байта атрибутов: flip_v (бит 7) и flip_h (бит 6). Считаем row, строку внутри спрайта, и при вертикальном отражении переворачиваем её относительно высоты: height - 1 - row. Дальше вычисляем адрес паттерна в CHR, отдельно для 8x16 (где старшие биты тайла и его половинки кодируются хитро) и для обычных 8x8.
Читаем два байта плана (младший lo и старший hi, второй на 8 байт дальше). Горизонтальное отражение это просто разворот битов: reverse_bits() меняет порядок битов в байте, и спрайт смотрит в другую сторону без всякой отдельной логики на пиксельном уровне. Готовые байты кладём в сдвиговые регистры под индексом i.
Сдвиг регистров каждый такт
Пока строка выводится, на каждой точке надо двигать конвейер: сдвигать фоновые регистры и продвигать спрайтовые либо уменьшать их X-счётчики, пока спрайт ещё не дошёл до текущего пикселя. Этим занимается update_shifters, его зовёт конвейер выборки на каждой видимой точке:
fn update_shifters(&mut self) {
if self.mask & MASK_SHOW_BG != 0 {
self.bg_shift_pattern_lo <<= 1;
self.bg_shift_pattern_hi <<= 1;
self.bg_shift_attrib_lo <<= 1;
self.bg_shift_attrib_hi <<= 1;
}
if self.mask & MASK_SHOW_SPRITES != 0 && (1..=257).contains(&self.dot) {
for i in 0..self.sprite_count {
if self.scanline_sprites[i].x > 0 {
self.scanline_sprites[i].x -= 1;
} else {
self.sprite_shift_lo[i] <<= 1;
self.sprite_shift_hi[i] <<= 1;
}
}
}
}
Верхний блок двигает фон, мы его уже разбирали в прошлом уроке. Нижний это спрайты. Идея аппаратная: у каждого спрайта свой счётчик X. Пока он больше нуля, спрайт ещё левее текущего пикселя, и мы уменьшаем счётчик. Когда X добежал до нуля, спрайт стоит под лучом, и мы начинаем сдвигать его сдвиговые регистры влево на один бит за такт, выкатывая по пикселю на старший бит. Так координата X превращается в задержку, а не в индекс: каждый спрайт сам по себе доезжает до своего места на строке.
Приоритет, прозрачность и sprite 0 hit
Теперь главный метод урока, render_pixel. Он считает один пиксель экрана: собирает кандидата от фона, собирает кандидата от спрайтов и мультиплексирует их по правилам прозрачности и приоритета. Цвет с индексом 0 в палитре это прозрачность: сквозь него видно нижний слой. Метод целиком, чтобы видеть, как фон и спрайт реально сходятся:
/// Сосчитать и записать один пиксель экрана на текущей видимой точке.
fn render_pixel(&mut self) {
let x = self.dot - 1;
let y = self.scanline as u16;
// --- фон ---
let (mut bg_pixel, mut bg_palette) = (0u8, 0u8);
if self.mask & MASK_SHOW_BG != 0 && (self.mask & MASK_BG_LEFT != 0 || x >= 8) {
let bit = 0x8000u16 >> self.fine_x;
let p0 = ((self.bg_shift_pattern_lo & bit) != 0) as u8;
let p1 = ((self.bg_shift_pattern_hi & bit) != 0) as u8;
bg_pixel = (p1 << 1) | p0;
let a0 = ((self.bg_shift_attrib_lo & bit) != 0) as u8;
let a1 = ((self.bg_shift_attrib_hi & bit) != 0) as u8;
bg_palette = (a1 << 1) | a0;
}
// --- спрайты ---
let (mut fg_pixel, mut fg_palette, mut fg_priority) = (0u8, 0u8, false);
self.sprite_zero_rendered = false;
if self.mask & MASK_SHOW_SPRITES != 0 && (self.mask & MASK_SPRITES_LEFT != 0 || x >= 8) {
for i in 0..self.sprite_count {
// Спрайт активен, когда его счётчик X добежал до нуля.
if self.scanline_sprites[i].x != 0 {
continue;
}
let p0 = ((self.sprite_shift_lo[i] & 0x80) != 0) as u8;
let p1 = ((self.sprite_shift_hi[i] & 0x80) != 0) as u8;
let pixel = (p1 << 1) | p0;
if pixel == 0 {
continue; // прозрачный пиксель спрайта
}
fg_pixel = pixel;
fg_palette = (self.scanline_sprites[i].attr & 0x03) + 4;
fg_priority = self.scanline_sprites[i].attr & 0x20 == 0;
if self.scanline_sprites[i].index == 0 {
self.sprite_zero_rendered = true;
}
break; // первый непрозрачный спрайт по приоритету OAM выигрывает
}
}
// --- мультиплексор фона и спрайта ---
let (pixel, palette) = match (bg_pixel, fg_pixel) {
(0, 0) => (0, 0),
(0, _) => (fg_pixel, fg_palette),
(_, 0) => (bg_pixel, bg_palette),
_ => {
// Оба непрозрачны: решает приоритет спрайта.
// И ровно здесь возможен sprite 0 hit.
if self.sprite_zero_possible
&& self.sprite_zero_rendered
&& self.mask & MASK_SHOW_BG != 0
&& self.mask & MASK_SHOW_SPRITES != 0
&& x != 255
{
self.status |= STATUS_SPRITE0;
}
if fg_priority {
(fg_pixel, fg_palette)
} else {
(bg_pixel, bg_palette)
}
}
};
let color_index =
self.palette_ram[Self::palette_index(0x3F00 | ((palette as u16) << 2) | pixel as u16)];
self.frame[y as usize * WIDTH + x as usize] = rgb(color_index);
}
Блок фона собирает двухбитный bg_pixel и номер палитры из фоновых сдвиговых регистров с поправкой на тонкий скролл fine_x, мы это уже видели. Блок спрайтов идёт по отобранным спрайтам в порядке OAM, пропускает те, чей счётчик X ещё не дошёл до нуля, читает пиксель из старшего бита сдвиговых регистров и берёт первый непрозрачный. На нём же выставляется fg_priority (бит 5 атрибута: спрайт перед фоном или за ним) и, если это нулевой спрайт, флаг sprite_zero_rendered. После первого попадания break: побеждает первый по приоритету OAM.
Сердце метода это мультиплексор, match из четырёх случаев. Оба прозрачны, видно общий фон палитры. Только спрайт, берём спрайт. Только фон, берём фон. А когда оба непрозрачны, решает fg_priority, и ровно в этой ветке возможен sprite 0 hit. Вся графика NES это вот этот match, повторённый для каждого пикселя.
Sprite 0 hit это самый хитрый бит блока. Он не про графику, а про синхронизацию: игра ставит нулевой спрайт в известную точку и по моменту хита узнаёт, где сейчас луч, чтобы переключить скролл в середине кадра. Половина игр со статус-баром держится на нём, поэтому тайминг хита надо ловить точно, такт в такт.
Условие в коде читается прямо: хит, когда оба пикселя непрозрачны (это ветка _ мультиплексора), при отборе на строку попал нулевой спрайт (sprite_zero_possible) и его пиксель реально вышел (sprite_zero_rendered), оба слоя включены в маске, и x != 255 (на последнем пикселе строки железо хит не ставит, такой квирк). Покрути виджет: двигай спрайт 0 по строке, смотри, на какой точке загорается флаг, и как игра по этой точке делит экран на неподвижный верх и скроллящийся низ.
Восемь на строку и тайминг
Ограничение восьми спрайтов на строку это аппаратное свойство вторичного буфера, и эмулятор обязан его соблюдать. Иначе картинка разойдётся с оригиналом: пропадёт мерцание, которое игра закладывала как часть стиля. Ты уже видел это ограничение в evaluate_sprites (ветка if self.sprite_count == 8) и счётчик X в update_shifters и render_pixel, тайминг весь там.
Проверка
Новый файл мы не трогали, так что и тесты те же. Все эти тонкости (когда именно взводится хит, как считается переполнение, активен ли спрайт по своему счётчику X) проверяются тем же cargo test --test ppu, что ты гонял в прошлом уроке: тест-файл tests/ppu.rs общий для фона и спрайтов. Поверх него специализированные тест-ROM с NESdev (для sprite 0 hit и для overflow) гоняются как обычный cargo test и точечно бьют по этим квиркам. Это тот же подход golden-тестов, что и nestest для ядра: эталонный ROM знает правильный ответ, а наш эмулятор должен с ним сойтись.
Одного куска для полноты картины не хватает: OAM мы пока ничем не заполняем. В железе это делает DMA по адресу $4014, которая копирует страницу RAM в OAM целиком. Эту передачу мы подключим к шине в финале блока, и только тогда спрайты реально появятся на экране в живой игре.
Дальше
Картинка готова целиком: фон плюс спрайты. Осталась третья машина, которая молчала весь блок. Дальше APU и звук: каналы, огибающие и вывод аудио без щелчков.