Живой космос на Metal: как я переписывал фон мобильной игры и поднимал FPS с 20 до 120

от автора

В мобильной разработке мы привыкли, что красивый анимированный фон — это либо видео, либо пара слоёв с CAGradientLayer и медленным параллаксом. Для большинства задач этого хватает. Но иногда хочется большего: настоящий живой космос — туманности, которые медленно перетекают друг в друга, планеты с кольцами, звёзды разных классов и в центре — чёрная дыра с аккреционным диском, как в «Интерстелларе».

У меня есть небольшая аркада-раннер про полёт сквозь космос. Фон там — не картинка, а процедурная сцена, которую целиком рисует фрагментный шейдер: каждый пиксель экрана вычисляется математикой из шума. Сначала всё это жило на SpriteKit, а потом я переписал на Metal — и вот тут началось самое интересное, потому что красивая сцена на десктопе и та же сцена на телефоне — это две очень разные истории.

Расскажу, как я это делал, на какие грабли наступил, и почему в итоге пришлось рисовать фон… полосками.

Что было: SpriteKit и один большой шейдер

Изначально фон был сделан максимально «в лоб»: полноэкранный SKSpriteNode, на который повешен SKShader — фрагментный шейдер на GLSL-подобном диалекте SpriteKit. Шейдер на каждый пиксель считает всю сцену: несколько слоёв звёзд с параллаксом, туманность через доменный варп (domain warp — это когда координаты сэмплирования сами искажаются шумом, чтобы облака закручивались), пустоты, пыль.

Для тех, кто не писал шейдеры: фрагментный шейдер — это маленькая программа, которая выполняется параллельно для каждого пикселя кадра. На экране iPhone это легко 3–4 миллиона пикселей, и каждый кадр (60 раз в секунду) для каждого из них мы прогоняем всю эту математику заново.

А математика там недешёвая. Туманность считает функцию fbm (fractional Brownian motion — сумма нескольких октав шума), причём перед этим трижды вызывает warp, а каждый warp — это ещё три fbm. Итого только на облака — несколько десятков вычислений шума на пиксель. Плюс звёзды, плюс пыль. И всё это — на полном retina-разрешении (×3), каждый кадр.

Наивное предположение здесь такое: «ну это же шейдер, его GPU и так тянет, что ему сделается». На телефоне в спокойном режиме оно действительно работало. Но как только сверху появлялась реальная игра с её собственной отрисовкой — начинались просадки. И главное — я хотел больше: чёрную дыру, планеты, гигантов. А SpriteKit-шейдер на это уже не тянул.

Идея: не считать то, что не меняется

Ключевое наблюдение: фон меняется медленно. Туманность перетекает за секунды, планеты ползут параллаксом по чуть-чуть, звёзды мерцают еле-еле. Зачем пересчитывать эти десятки октав шума 60 раз в секунду, если за один кадр картинка почти не изменилась?

Отсюда — двухпроходная архитектура, которую я перенёс из своего же десктопного Metal-приложения:

  1. Static pass — рисует все «медленные» слои (фон, звёзды, галактики, туманность, планеты, гиганты, чёрную дыру) в offscreen-текстуру формата rgba16Float. Float16 нужен, чтобы сохранить HDR — у аккреционного диска и звёзд яркость сильно выше единицы, и её нельзя «сплющить» раньше времени. Этот проход запускается редко.

  2. Composite pass — запускается каждый кадр: берёт закешированную текстуру, добавляет дешёвые «быстрые» слои (кометы) и делает тон-маппинг (ACES) + виньетку.

Идея простая и правильная: дорогое считаем редко, дешёвое — каждый кадр.

Технически фон — это MTKView, который лежит за прозрачной SpriteKit-сценой (allowsTransparency, scene.backgroundColor = .clear). Почему не остаться в SpriteKit? Потому что SKShader не умеет offscreen-кэш с пинг-понгом текстур — это один фрагментный проход на узел, каждый кадр, и точка. А весь смысл оптимизации — именно в кэше.

Маленькая деталь, которая сэкономила нервы: сам MSL-шейдер я вшил в код как Swift raw-string и компилирую в рантайме через device.makeLibrary(source:). Так я не зависел от того, как система сборки (у меня Tuist + статический фреймворк) положит default.metallib в бандл — а это, поверьте, отдельный источник боли.

Собрал, запустил на устройстве. Красиво. А потом…

Первая стена: «сначала норм, потом всё виснет»

Первый же отзыв тестировщика (меня самого) был: «играешь — сначала всё хорошо, а потом начинает дико тормозить, и чем дальше, тем хуже».

Вот это «чем дальше, тем хуже» — золотая улика. Постоянная нагрузка тормозила бы равномерно с самого начала. А прогрессирующая деградация почти всегда означает, что что-то накапливается со временем.

Что я добавил в систему? Отдельный MTKView со своим циклом отрисовки. Значит, копать надо там. И действительно — в моём draw(in:) не хватало главного: синхронизации CPU и GPU.

Дело в том, что MTKView под капотом — это CADisplayLink: таймер, синхронизированный с реальным обновлением экрана, который дёргает draw() ровно тогда, когда дисплей готов принять кадр. Обычно его вспоминают как палочку-выручалочку для плавных кастомных анимаций — но у медали есть и обратная сторона. Я в каждом кадре делал commandQueue.makeCommandBuffer(), кодировал проходы и commit()не дожидаясь GPU. Пока GPU успевал — всё хорошо. Но как только тяжёлый static-проход переставал укладываться в бюджет кадра, командные буферы начинали копиться в очереди. У очереди есть лимит, и при его достижении makeCommandBuffer() блокирует main thread. А на main thread живёт и SpriteKit-игра. Итог: задержка растёт, растёт, и в какой-то момент висит уже всё приложение. В логах прямо видно: SKView: no drawables available for rendering. Skipping this frame — игра роняет кадры, потому что GPU занят моим фоном.

Лечение — стандартный паттерн синхронизации CPU и GPU через семафор:

private static let maxFramesInFlight = 3private let frameSemaphore = DispatchSemaphore(value: maxFramesInFlight)// + кольцо из 3 uniform-буферов, чтобы CPU не переписывал буфер,//   который GPU прямо сейчас читаетfunc draw(in view: MTKView) {    // НЕблокирующий захват: если GPU ещё занят прошлыми кадрами фона —    // просто пропускаем кадр фона, а не вешаем main thread.    guard frameSemaphore.wait(timeout: .now()) == .success else { return }    let cb = commandQueue.makeCommandBuffer()!    cb.addCompletedHandler { [frameSemaphore] _ in frameSemaphore.signal() }    // ... кодируем проходы ...    cb.commit()}

Тонкость, которой я горжусь: wait(timeout: .now()) вместо блокирующего wait(). Фон — это фон. Если видеокарта не справляется, я лучше пропущу кадр фона (он просто чуть просядет по FPS, на медленной туманности это незаметно), чем заблокирую main thread и уроню игру. Прогрессирующее зависание ушло.

Профилирование: пик важнее среднего

Прогрессию убрали, но «в среднем тяжело» осталось. Тут я допустил типичную ошибку — начал оптимизировать «на глаз». Остановился и решил измерить.

Самый честный способ узнать, сколько времени проход реально занимает на GPU — это commandBuffer.gpuStartTime и gpuEndTime в completion-handler’е. Я просто печатал разницу в stdout и снимал её с устройства через devicectl device process launch --console. Никаких трейсов парсить не пришлось.

Цифры оказались внезапными:

Проход

Время на GPU

Composite (каждый кадр)

~0.85 мс

Static (раз в N кадров)

~17 мс

Composite — копеечный. А вот static-проход — это монолитный 17-миллисекундный «удар» по GPU. И вот ключевой инсайт, ради которого стоило мерить: проблема была не в средней нагрузке (она была вполне скромной, процентов 15–20), а в пике.

Смотрите: бюджет кадра при 60 Гц — 16.7 мс, при 120 Гц (ProMotion) — всего 8.3 мс. И вот раз в несколько кадров прилетает задача на 17 мс, которая занимает GPU целиком. В этот момент игра, делящая ту же видеокарту, не может дорисовать свой кадр — отсюда рывки ровно с частотой обновления кэша. Сделать проход просто «дешевле» — полумера: даже 13 мс раз в N кадров всё равно не влезают в 8.3 мс бюджета 120 Гц.

То есть бороться надо было не (только) со стоимостью, а с монолитностью.

Сначала — режем жир

Но прежде чем заняться монолитностью, очевидное: уменьшить саму стоимость. Профиль (и здравый смысл) показывали, что дороже всего — чёрная дыра и звёзды-гиганты.

Чёрная дыра — это порт «интерстелларовского» рейтрейсера с Shadertoy: для каждого пикселя луч искривляется гравитацией в цикле, и сцена за дырой видна как бы сквозь линзу. Дорого там было сразу всё:

  • Луч на «выходе» (когда он улетает мимо дыры) сэмплировал фон — и в оригинале фоном был пересчёт всей процедурной сцены целиком (звёзды + галактики + туманность) на каждый такой пиксель. Я заменил это на дешёвый одно-октавный тинт: за искажённой линзой дыры всё равно ничего детально не разглядеть.

  • Зона влияния дыры (ROI) в портретной ориентации накрывала почти весь экран — то есть тяжёлый raymarch шёл едва ли не на каждом пикселе. Я её сильно ужал.

  • Урезал число итераций линзирования и шагов трассировки диска.

Красный гигант рисовался как кипящая плазма с «языками» — цикл из 10 протуберанцев, и в каждом — трёхмерный шум (а 3D-шум дороже 2D в разы). Сократил до 6 и поджал радиусы, на которых работают тяжёлые циклы.

Туманность — убрал один из трёх warp (это сразу минус 9 вычислений шума на каждый пиксель экрана) и переиспользовал уже посчитанные поля вместо новых.

Плюс снизил renderScale — рендерил кэш ниже нативного разрешения (для мягкой туманности это незаметно). Static-проход похудел с 17 до ~13.5 мс. Лучше, но всё ещё «удар». Пора было решать монолитность.

Главный трюк: рисуем фон полосками

Раз один большой проход стопорит игру — давайте разрежем его на куски и будем рисовать по одной горизонтальной полосе за кадр.

Делается это через setScissorRect — прямоугольник отсечения. Включаем loadAction = .load (чтобы не затереть полосы, которые рисовали в прошлые кадры), ставим scissor на нужную полосу — и рисуем только её. Полный кадр кэша собирается за numStrips кадров.

// каждый кадр — одна полоса; полный рефреш за numStrips кадровlet stripH = (target.height + Self.numStrips - 1) / Self.numStripslet y = stripIndex * stripHencoder.setScissorRect(MTLScissorRect(x: 0, y: y, width: target.width,                                      height: min(stripH, target.height - y)))encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6)

И вот тут важный момент, почему это вообще экономит, а не просто «размазывает». GPU в Apple — TBDR (tile-based deferred rendering): он рисует не сразу весь экран, а плитками. Scissor отсекает фрагменты до запуска фрагментного шейдера — то есть плитки вне полосы просто не обрабатываются. Поэтому полоса в 1/N экрана честно стоит примерно 1/N от полного прохода. Пик с 13 мс падает до пары-тройки.

Звучит как победа, но появился новый артефакт — и он показал, что у этой схемы есть свой параметр-компромисс.

Полоски против плавности

Я поставил numStrips = 4 при 30 Гц у MTKView. Пик упал, игра поехала плавно — а вот туманность задёргалась.

Причина в одной строчке арифметики: раз кэш полностью обновляется за numStrips кадров, то анимация фона идёт с частотой fps / numStrips. При 4 полосах и 30 Гц это 7.5 Гц — глаз отлично видит дискретные «прыжки» медленно плывущей туманности.

Очевидное решение — меньше полос. numStrips = 2 даёт 15 Гц, плавнее. Но тут вылез баланс: чёрная дыра расположена в верхней части экрана и при двух полосах целиком попадает в одну полосу — и эта полоса снова становится «ударом». Полосы получаются неравномерными по стоимости.

Правильный ответ нашёлся в той же формуле fps / numStrips. Я поднял MTKView до 60 Гц и оставил numStrips = 4:

  • частота анимации = 60/4 = 15 Гц (плавно, как при N=2);

  • но пик = одна полоса из четырёх ≈ 5 мс (как при N=4) — и чёрная дыра размазана сразу по трём полосам, нет «тяжёлой» полосы;

  • суммарная работа за секунду — ровно как у N=2 при 30 Гц.

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

Финальный замер: ~6 мс/кадр на фон, без «ударов». Полная HDR-сцена с чёрной дырой едет рядом с игрой на 120 Гц.

Бонус-баг: космос, который уехал за край

Под конец — маленькая история в духе «очевидно в ретроспективе». Я перенёс сцену с десктопа, запустил на телефоне — и на экране только одна планета. Где красный гигант? Где синий сверхгигант? Где ещё три планеты?

Разгадка — в системе координат. Позиции объектов заданы в пространстве p = (uv - 0.5) * vec2(aspect, 1). На широком экране Mac aspect ≈ 1.6, и видимый диапазон по X — примерно ±0.8. А в портрете телефона aspect ≈ 0.46, и по X видно только ±0.23. Все объекты, у которых X по модулю больше ~0.2 (а это почти все), просто уехали за левый и правый край. На экране осталась лишь одна планета, у которой X случайно был близок к нулю.

Лечится одной функцией, которая раскладывает «десктопные» позиции по фактическому кадру при любом соотношении сторон:

float2 placeInFrame(float2 designPos, float aspect) {    float2 rel = clamp(designPos / float2(0.7, 0.6), -1.0, 1.0); // -> [-1,1]    float2 half = float2(aspect * 0.5, 0.5) * 0.86;              // с отступом от краёв    return rel * half;}

Мораль тут вечная: баг кажется мистическим ровно до того момента, как ты вспоминаешь, в каких единицах вообще живут твои числа.

Что в итоге

  • Дорогое (звёзды, туманность, планеты, чёрная дыра) считается редко, в HDR-кэш; дешёвое (кометы, тон-маппинг) — каждый кадр.

  • Цикл MTKView синхронизирован семафором + кольцом буферов + autoreleasepool — никакого неограниченного роста очереди и зависаний.

  • Тяжёлый проход разрезан на полосы через scissor — нет монолитного «удара» по GPU, игра не страдает.

  • Параметры fps и numStrips подобраны так, чтобы и анимация была плавной (15 Гц), и пик низким (~5 мс).

  • А ещё я научился сначала мерить (gpuStartTime/gpuEndTime), а потом оптимизировать — и не наоборот.

И главное наблюдение, которое я унёс из этой задачи: на мобильном GPU часто важна не средняя нагрузка, а равномерность. Один редкий тяжёлый кадр способен испортить впечатление сильнее, чем стабильно высокая, но размазанная нагрузка. Иногда правильный ответ — не «сделать дешевле», а «сделать ровнее».

Не бойтесь лезть в незнакомые для вас области — будь то рейтрейсинг чёрных дыр или особенности tile-based рендеринга. Самые интересные решения живут как раз на стыке «я думал, это просто шейдер» и «оказывается, тут целая архитектура». А удовольствие от того, что оно в итоге едет плавно на 120 кадрах — отдельное.

Скриншот из игры

Скриншот из игры

ссылка на оригинал статьи https://habr.com/ru/articles/1049396/