В мобильной разработке мы привыкли, что красивый анимированный фон — это либо видео, либо пара слоёв с 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-приложения:
Static pass — рисует все «медленные» слои (фон, звёзды, галактики, туманность, планеты, гиганты, чёрную дыру) в offscreen-текстуру формата
rgba16Float. Float16 нужен, чтобы сохранить HDR — у аккреционного диска и звёзд яркость сильно выше единицы, и её нельзя «сплющить» раньше времени. Этот проход запускается редко.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 = 3 private 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.numStrips let y = stripIndex * stripH encoder.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 кадрах — отдельное.
