Эта статья про инженерную сторону image slide puzzle: как произвольное фото становится играбельным N×N пазлом. Это часть, которую игроки никогда не видят, а разработчики тратят на неё больше времени, чем ожидают.
Конвейер
Три стадии, по порядку:
1. Квадратный crop
Большинство фото не квадратные. HEIC-файлы iPhone — 4:3; фото, снятые в портретной — 3:4. Доска слайд-пазла — 1:1. Шаг один — выбрать, какой квадратный регион фото играть.
Два распространённых подхода:
Центрированный crop. Берёт самый большой центрированный квадрат, помещающийся внутри. Быстро, предсказуемо, иногда неправильно (субъект не в центре).
Интерактивный crop. Показывает пользователю перетаскиваемый квадрат. Пусть выбирает. Медленнее, но пользователь всегда получает что хотел.
Slide Puzzle использует интерактивный crop, по умолчанию центрированный. Crop неразрушающий — оригинал в библиотеке фото никогда не меняется.
2. Уменьшение до рабочего разрешения
После кропа изображение обычно всё ещё 3000×3000 или больше (зависит от камеры). Для игры в пазл это перебор. Доска 6×6, показываемая в 320pt на 3× экране iPhone, рендерит каждую плитку в около 53pt = 160 пикселей устройства. Исходное разрешение 1024×1024 даёт примерно 170 пикселей на плитку — резче, чем экран может показать.
Уменьшение до 1024 (иногда 2048) — стандарт. Большие источники тратят память и замедляют загрузку без улучшения видимого результата.
3. Рендер плиток по требованию
Это часть, которую большинство ошибочно реализует с первой попытки. Изображение на самом деле не режется на 16 отдельных файлов. Это было бы медленно, расточительно и не нужно.
Вместо этого приложение рендерит каждую плитку, отрисовывая одно и то же исходное изображение в прямоугольник размером с плитку, со смещением исходного прямоугольника на позицию плитки в целевом изображении. CSS называет это трюком «background-position»; iOS — это клиппинг прямоугольника CGImage; SwiftUI использует Rectangle().clipped() поверх Image.
В псевдокоде, отрисовать плитку в целевой позиции (row, col) на N×N доске с изображением стороны S:
draw image at (-col * S/N, -row * S/N)
within a clip rectangle of (S/N × S/N)
Это всё. Шестнадцать плиток для 4×4 — это 16 вызовов рендера того же изображения с 16 разными смещениями. Приложение никогда не режет файл.
И поэтому нарезка доски 6×6 мгновенна: это 36 вызовов того же рендерера, а не 36 файловых операций.
А как же анимация
Сдвиг плитки — это transform-перевод отрисованной плитки. Содержимое внутри плитки не меняется — только позиция кадра-клиппинга на экране. Анимация сдвига становится тривиальной для GPU и идёт в 120 Гц на дисплеях ProMotion.
Три детали реализации, которые имеют значение:
- Клип и контент — соседи, не родитель/ребёнок. Если вложены, контент двигается вместе с клипом и сдвиг ломается.
- Используйте transform, не layout. Анимация x/y через CSS transform или SwiftUI offset ускорена GPU; анимация left/top — нет.
- Преобразуйте в растр заранее при первом появлении. Первый кадр рендера плитки может быть медленным, потому что исходное изображение нужно декодировать. Отрендерьте все плитки один раз в начале игры, чтобы разогреть их.
Хранение
Где реально живёт импортированное изображение?
В уважающем приватность приложении — в песочнице приложения, зашифрованной iOS в покое. Библиотека фото по-прежнему хранит оригинал; приложение хранит рабочую копию 1024×1024 внутри своей папки Documents. Когда пользователь удаляет приложение, рабочая копия удаляется с ним.
В облачном приложении рабочая копия живёт где-то на сервере. Последствия очень разные — для приватности, для оффлайн-игры и для того, что случится, когда сервер исчезнет. Slide Puzzle — это песочница-вариант.
Бюджет памяти
Типичный 4×4 image slide puzzle:
- Исходное изображение: ~3 МБ JPEG.
- Декодированное в памяти: 1024 × 1024 × 4 байта = 4 МБ.
- Одна копия на активную игру.
Нормально. На 6×6 источник и буфер декода того же размера. Доске больше памяти не нужно.
Где бюджеты памяти ужесточаются — это в библиотеках обложек: 300 обложек × 4 МБ = 1,2 ГБ, если декодировать все сразу. Приложения избегают этого через декод по требованию (только когда обложка показана) и освобождение при бэкграунде (в памяти только изображение активной игры).
Граничные случаи
Три вещи, которые ломаются на практике:
Фото с тегом ориентации EXIF. Фото, снятое в портретной, но сохранённое в ландшафтной с тегом поворота, отобразится правильно в библиотеке фото и неправильно в пазле, если приложение забыло применить EXIF-поворот. Первая версия каждого фото-пазл-приложения имеет этот баг.
Очень большие исходные изображения. Некоторые HEIC-файлы — 6000×8000 пикселей. Загрузка в память в полном разрешении упадёт на меньших iPhone. Лечение — потоковое декодирование с уменьшением до целевого размера: Apple ImageIO поддерживает. Декодируйте в 2048×2048 сразу с диска; никогда не декодируйте в полном размере.
Субпиксельный рендеринг. Размеры плиток, не делящиеся точно на пиксельную сетку экрана, создают дробный пиксельный столбец на одной стороне каждой плитки. С чистым клиппингом это видно как волосок-зазор. Лечите либо привязкой размеров плиток к целым пикселям (видимый дрожь на нестандартных размерах), либо позволяя плиткам перекрывать на 0,5 пикселя (нет зазора, нет дрожи).
Это не глубокие проблемы, но их единодушно забывают первые реализаторы.
Сводка
Конвейер: crop → resize → клип-и-перевод для рендера плиток → translate transforms для анимации сдвигов. В приложении, уважающем приватность, изображение никогда не покидает устройство. Весь конвейер укладывается в около 200 строк кода, плюс ещё 50 на обработку EXIF-поворота, которую все забывают в первый раз.
Image slide puzzles проще, чем кажутся снаружи. Изображение остаётся одним изображением; плитки — это просто оформленные виды на него.