Files
Sprinter-SDCC/applications/PoP/docs/shadow_atlas_plan.md
T

13 KiB
Raw Blame History

Атлас Тени — разбор и план

Дата: 2026-08-20. Статус: разбор сделан, код не писали.

Задача: сейчас Тень рисуется спрайтами Кида (вне боя) и спрайтами стража (в бою) как есть, поэтому от Кида она не отличается. В оригинале её вид даёт наложение спрайта на себя со сдвигом; наш пакетный блит такого не умеет, поэтому запекаем результат в отдельный атлас заранее.

1. Что делает оригинал (проверено по SDLPoP, не по памяти)

draw_objtable_item, seg008.c:1600:

case 1: // shadow
    add_midtable(obj_chtab, obj_id + 1, obj_xh, obj_xl,     obj_y, blitters_2_or,  1);
    add_midtable(obj_chtab, obj_id + 1, obj_xh, obj_xl + 1, obj_y, blitters_3_xor, 1);

Тот же самый спрайт кладётся ДВАЖДЫ: первый проход в x, второй в x+1. Два уточнения, которые меняют алгоритм запекания:

  1. blitters_2_or — это НЕ побитовое ИЛИ. В SDLPoP он реализован обычным блитом с colour key = индекс 0 (method_6_blit_img_to_scr, seg009.c:3306: SDL_SetColorKey(image, SDL_TRUE, 0)). То есть первый проход — наш обычный прозрачный блит, один в один.
  2. blitters_3_xor работает по 24-битному RGB, а не по индексам палитры (blit_xor, seg009.c:3190: конвертация в 24 бита, затем *p_dest ^= *p_src побайтно). Прозрачности у него нет вообще — XOR'ится весь прямоугольник, но прозрачные пиксели спрайта это чёрный 0x000000, а XOR с нулём ничего не меняет.

Отсюда и берётся необходимость СВОЕЙ палитры: XOR двух цветов игровой палитры даёт цвет, которого в ней нет.

2. Единственное место, где запечка отличается от оригинала

XOR идёт по тому, что УЖЕ на экране, то есть результат зависит от фона. Разбор по пикселям показывает, что зависимость узкая:

пиксель первый проход второй проход зависит от фона?
спрайт непрозрачен в x закрашен цветом спрайта XOR с цветом из x1 нет
прозрачен в x, непрозрачен в x−1 фон фон XOR цвет да
прозрачен в обоих фон фон нет (не рисуем)

То есть от фона зависит только кайма в один пиксель по левым кромкам силуэта. На чёрном фоне (а Тень почти всегда на нём — уровень 4 у зеркала, уровень 6, бой на 12-м) фон XOR цвет == цвет, и запечка точна. На светлом фоне оригинал подкрасит эту кайму, мы — нет.

Это осознанное расхождение, в impl_diff.md при реализации.

3. Замеры на реальных ассетах

Прогон алгоритма по всем кадрам (SDLPoP/data/KID, SDLPoP/data/GUARD):

набор кадров макс. габарит разных цветов
Кид (chtab_2) 219 56×57 44
Страж (chtab_5) 34 53×42 16
вместе 253 57×57 (с учётом +1 px сдвига) 59

Всего пикселей во всех кадрах Тени — 96 746. Квантование до N цветов (оставляем N самых частых, остальные — в ближайший):

палитра затронуто пикселей доля худшая ошибка (евклид RGB)
16 цветов 3 483 3,60 % 151
32 цвета 426 0,44 % 113

Насколько заметна подмена

«Затронуто» само по себе ничего не говорит — важно, НА СКОЛЬКО сместился цвет. Порог различимости на плоской заливке ~30-40 единиц евклида в RGB (максимум возможного — 441). Пробовал три стратегии подбора:

  • A — оставить N самых частых цветов ТОЧНО, остальные в ближайший;
  • B — взвешенный k-means по всем цветам (двигает вообще все);
  • C — гибрид: часть слотов под точные частые, остаток — кластеры хвоста.
палитра стратегия изменено px заметно (40..90) сильно (>90) худшая средняя
16 A самые частые 3 483 (3,60 %) 2 342 257 151 57,7
16 B k-means 29 368 (30,4 %) 1 972 256 142 10,9
16 C гибрид 10+6 7 255 (7,50 %) 2 146 129 142 33,7
32 A самые частые 426 (0,44 %) 205 27 113 46,0
32 B k-means 3 425 (3,54 %) 133 27 110 9,1
32 C гибрид 20+12 1 997 (2,06 %) 133 27 110 16,0

Читается так. При 32 цветах стратегия не важна: «сильно» — ровно 27 пикселей на все 253 кадра в любой из трёх, это неустранимые одиночки без близкого соседа (по 1-3 пикселя на кадр, увидеть нельзя). Перцептивно значимого остаётся 232 пикселя из 96 746, то есть примерно один пиксель на кадр. При 16 цветах разница уже есть: гибрид вдвое срезает «сильно» (257 → 129), но заметных всё равно две с лишним тысячи.

Решение: 16 цветов, стратегия C (гибрид 10 + 6)

Числа выше — про пиксели, а решает глаз. Отрендерил одни и те же кадры в трёх видах (точный цвет / 32 / 16) и сравнил в увеличении ×3: отличий не видно. Объяснение в самих числах: перцептивно значимых пикселей при 16 цветах 2 275 на 253 кадра, это ~9 пикселей на кадр при ~1 000 видимых, и они РАССЫПАНЫ по контуру, а не собраны в пятно.

Поэтому берём 16, а не 32: экономим блок палитры (пригодится под будущие наборы — принцесса, визирь, мышь), а разница неразличима.

Но стратегия обязана быть гибридной. При 16 цветах «взять самые частые» вдвое хуже по грубым промахам (257 пикселей против 129), и это единственное место, где выбор стратегии виден. Гибрид: 10 самых частых берём ТОЧНО, оставшиеся 6 слотов отдаём под взвешенные кластеры хвоста.

Если в реальной игре кайма всё же будет резать глаз — переход на 32 цвета это одна константа в упаковщике и один блок палитры, данные не меняются.

4. Палитра: что занято и куда класть

блок кто примечание
0x30..0x3F VGA-16 общая; из неё цвет вспышки, пузырьки зелий, отладочная метка
0x40..0x4F chtab_1 зелья, пламя
0x50..0x5F env тайлсета меняется подземелье/дворец
0x60..0x6F wall тайлсета меняется подземелье/дворец
0x70..0x7F chtab_2 Кид
0x80..0x8F chtab_0 меч в руке
0x90..0x9F chtab_5 страж, перезаливается цветом стража

Занято 112 слотов из 256, свободно 144 — девять выровненных блоков по 16. Оговорки: 0xFF в наших атласах это маркер прозрачности, а запись 0 правит flash_bg, так что блоки 0x00 и 0xF0 лучше не трогать.

Берём 0xA0..0xAF (16 слотов) — сразу за стражем, персонажи остаются сгруппированы, а блок 0xB0 остаётся свободным (под 32 цвета Тени, если понадобится, или под будущие наборы).

Важно: в наших атласах прозрачность кодируется байтом 0xFF, а исходный индекс 0 в них означает «прозрачно». У Тени чёрный — настоящий цвет (это XOR-погашенная середина силуэта, 51 % всех её пикселей), поэтому у неё маппинг свой: «нет пикселя» → 0xFF, цвет k → 0xA0 + k, и слот 0xA0 = чёрный НЕПРОЗРАЧНЫЙ.

5. Объём

253 кадра против 219 у Кида — по страницам EMM примерно как нынешний набор Кида (28 страниц), плюс пара на кадры стража. При 215 свободных страницах на старте (memory sprinter_emm_budget) это не проблема.

Кадры смерти («убитый Кид») пока НЕ вырезаем — экономия несколько страниц, а риск промахнуться мимо нужного кадра реальный: Тень на 12-м уровне умирает.

6. План работ

Ш1. Упаковщик toolchain/pop_pack_shadow.py: прогнать оба набора через алгоритм §1, собрать 16-цветную палитру гибридом (10 точных + 6 кластеров хвоста), выдать poc/res/shadow/shadow0..N.atl + shadow.pal + pop_shadow_atlas.h. Критерий: предпросмотр PNG совпадает с видом Тени в SDLPoP.

Ш2. Загрузка: pop_shadow_load() рядом с pop_kid_load, палитра в 0xA0..0xAF, и обе страницы дабл-буфера (как bg_load_tile_pal). Грузить ЛЕНИВО — только когда на уровне есть Тень (4, 5, 6, 12), иначе 28 страниц EMM висят зря.

Ш3. Отрисовка: одно место — pop_cdraw.c:551..568, где выбирается pages. Сейчас там для соперника берётся gp, а для не-боевых кадров Тени подменяется на kidp; добавится ветка «charid == CHARID_1_SHADOW → shadowp», причём обе половины набора (кадры Кида и кадры стража) лежат в ОДНОМ атласе Тени, так что подмена становится проще нынешней.

Ш4. Проверка в MAME: уровень 4 (Тень у зеркала), уровень 6 (Тень крадёт зелье), уровень 12 (бой с Тенью — там она в боевых кадрах, то есть проверяется вторая половина набора).

7. Что проверить артефактом до Ш3

  1. Индекс кадра для Тени в боевых кадрах: frame_tbl_guard адресуется как frame + add_frame - 149 (seg006.c:535), то есть у нашего атласа Тени нумерация двух половин должна совпадать с тем, что уже делает pop_frame_tbl_is_guard.
  2. Цвет стража для Тени: у не-стражей curr_guard_color = 0 (seg002:183), значит запекать половину «кадры стража» надо с палитрой стража цвета 0, а не с текущей.