惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

C
Check Point Blog
罗磊的独立博客
博客园 - 叶小钗
Google DeepMind News
Google DeepMind News
Hugging Face - Blog
Hugging Face - Blog
人人都是产品经理
人人都是产品经理
J
Java Code Geeks
WordPress大学
WordPress大学
大猫的无限游戏
大猫的无限游戏
Blog — PlanetScale
Blog — PlanetScale
F
Fortinet All Blogs
小众软件
小众软件
M
MIT News - Artificial intelligence
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
酷 壳 – CoolShell
酷 壳 – CoolShell
The GitHub Blog
The GitHub Blog
A
About on SuperTechFans
Y
Y Combinator Blog
Recorded Future
Recorded Future
量子位
美团技术团队
S
Security @ Cisco Blogs
G
Google Developers Blog
Cyberwarzone
Cyberwarzone
C
Cybersecurity and Infrastructure Security Agency CISA
博客园 - 三生石上(FineUI控件)
博客园 - 司徒正美
D
Docker
S
Schneier on Security
T
Tor Project blog
阮一峰的网络日志
阮一峰的网络日志
T
Threatpost
P
Privacy & Cybersecurity Law Blog
C
Cisco Blogs
L
Lohrmann on Cybersecurity
NISL@THU
NISL@THU
Microsoft Azure Blog
Microsoft Azure Blog
博客园 - 聂微东
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
T
The Exploit Database - CXSecurity.com
A
Arctic Wolf
I
Intezer
Latest news
Latest news
Martin Fowler
Martin Fowler
G
GRAHAM CLULEY
B
Blog
V
Vulnerabilities – Threatpost
The Register - Security
The Register - Security
S
Securelist
T
Tenable Blog

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Живой космос на Metal: как я переписывал фон мобильной игры и поднимал FPS с 20 до 120
Нуждин Даниил · 2026-06-19 · via Все публикации подряд на Хабре

Средний

8 мин

7

В мобильной разработке мы привыкли, что красивый анимированный фон — это либо видео, либо пара слоёв с 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 = 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 кадрах — отдельное.

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

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