Раздел 23 · Rust

Спрайты

lead~40 мин

открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Спрайты

Фон неподвижен. Всё, что бегает, прыгает и стреляет, это спрайты: отдельные тайлы, которые 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 и звук: каналы, огибающие и вывод аудио без щелчков.

Домашка