С выходом iOS 26 Apple наконец-то обновила UI. Интерфейсы стали «стеклянными»: физически корректное преломление, жирная хроматическая аберрация, глубокий объем. Мне очень нравится как это выглядит, хотя в использовании, довольно часто, есть вопросы с удобством.

Но есть проблема: этот API (как и все другие новые от Apple) доступен только в новейшей системе. Если ваше приложение должно поддерживать iOS 18, 17 или (не дай бог) 14, то там всё по-старому: UIVisualEffectView, который блюрит фон.

Звучит как вызов и я захотел написать свой Liquid Glass на Metal + SwiftUI для iOS 16, который визуально будет 1-в-1 как нативный в iOS 26. Казалось бы, задача простая: пиши/найди/вайбкодь шейдер, и готово.

Спойлер: шейдеры оказались самой легкой частью (спасибо готовым решениям и LLM). А вот попытка заставить это работать в 120 фпс, без системных привилегий, уперлась в знаменитую «безопасность» системы iOS. О том, как я боролся с закрытым API, рекурсией и почему Apple может делать это быстро, а мы - нет, читайте под катом.


Часть 1. Шейдеры: Франкенштейн и LLM

Сразу признаюсь: я не силён в графике и не пишу шейдеры за кофе. Но для этой задачи это и не пригодилось.

Визуальную часть я собрал как конструктор. Нашел примеры на Shadertoy, вносил изменения и просил LLM обяснить что присходит в шейдерах и зачем.

В итоге мы получили рецепт того самого «жидкого» стекла:

  1. Blur: Использую MPSImageGaussianBlur для размытия на GPU. Это дает то самое «маслянистое», густое размытие без пикселей (в идеале нужно делать blur на шейдере, но этот компромисс для меня проще).

  2. SDF Refraction: Шейдер вычисляет signed distance field для скругленных углов и использует его градиент для имитации преломления света. Края объектов под стеклом красиво искажаются, давая эффект настоящего стекла.

  3. Псевдослучайный дизеринг: Добавляю процедурный шум для устранения бандинга на градиентах.

Вот примерный фрагмент логики (Metal), отвечающий за тот самый эффект дисперсии:

// Вычисляем SDF для скругленных углов
float sdf = boxSDF(centered, halfBoxSize - uniforms.cornerRadius);

// Получаем градиент для эффекта рефракции
float2 gradient = getGradient(centered, halfBoxSize, uniforms.cornerRadius);
float2 refractOffset = computeRefractOffset(gradient, sdf);

// Читаем размытую текстуру фона со смещением
half4 blurred = getBlurredColor(backgroundTexture, s, uv + refractOffset, blurLevel);

// Добавляем дизеринг для устранения бандинга
float2 noise = randomVec2(in.position.xy);
blurred.rgb += (noise.x - 0.5) * 0.02;

С самим шейдером проблем не было вообще. GPU iPhone (даже старых) перемалывает эту математику не напрягаясь. Проблемы начались там, где я их не ждал (кого я обманываю) - на этапе подачи данных в этот шейдер.

Часть 2. Запретный плод: CABackdropLayer

Чтобы стекло работало не жидко, шейдеру нужно знать, что находится под ним.

Как это делает Apple в iOS 26? У них есть CABackdropLayer. По сути, это прямой доступ к буферу рендера композитора. Система просто говорит: «Вот тебе кусок уже отрисованного экрана, делай жидкое стекло». Это происходит на уровне железа, мгновенно и без затрат CPU.

Буфер рендера композитора - это финальная картинка всех слоёв UI, которую система собирает перед выводом на экран.

Композитор (Window Server в iOS/macOS) - это системный процесс, который смешивает все эти слои в один кадр. У него уже есть готовая картинка слоёв в памяти GPU.

Можем ли мы использовать это?
Технически - конечно. По факту - nope. Достать приватный класс через Runtime - дело пяти минут. Я пробовал, это работает идеально: 120 FPS, полное соответствие нативному поведению новой iOS.

В чем подвох?
App Store Review. Использование приватных API - это гарантированный реджект в 99% случаев. Если вы делаете приложение in-house, то берите приватный API и кайфуйте. Но для App Store придется идти «легальным» путем.

Часть 3. «Легальный» путь и смерть производительности

Раз нам нельзя читать буфер экрана напрямую (как это делает система), остается один вариант: делать скриншоты области под стеклом.

И вот тут начинается боль, нет даже больше - боооль. Схема работы:

  1. CPU: Принудительно рендерим иерархию View под стеклом в картинку через layer.render(in:)

  2. GPU: Применяем Gaussian blur через Metal Performance Shaders

  3. GPU: Шейдер накладывает рефракцию и финальную красоту

Узкое место здесь - пункт №1. Шейдер + MPS blur отрабатывают очень быстро 0.5-2ms. А вот создание скриншота - это тяжелая операция. В iOS (даже старых версий) просто нет публичного API, чтобы сказать «дай мне текстуру того, что сейчас на экране» без оверхеда.

Попытка №1: drawHierarchy(afterScreenUpdates: true)

Самый честный метод. Захватывает всё: и SwiftUI тонкости, и UIKit.

  • Результат: Лаги. Метод синхронный. Пока он думает, интерфейс стоит. FPS падает до 15-20 при активном скролле.

Попытка №2: layer.render(in: context)

Работает быстрее и именно это используется в финальной версии.

  • Результат: Это суррогат. Он не захватывает некоторые системные эффекты и сложную анимацию (например не захватывает SwiftUI blur 😶‍🌫️). Но это лучшее, что есть из публичных API.

Получается парадокс: 90% времени кадра уходит не на красивый эффект (ради чего всё затевалось), а на бюрократию - попытку легально получить пиксели экрана.

Часть 4. Рекурсия (Bonus Track)

Еще один забавный момент - эффект «зеркального коридора».
Когда я делаю снимок экрана, в кадр попадает само мое стекло. На следующем кадре шейдер применяет эффект к снимку со стеклом. Получается бесконечная рекурсия, превращающая UI в кашу.

Пришлось писать костыли:

func captureSnapshot(for glassView: UIView) -> CGImage? {
    // ... подготовка
    
    let originalAlpha = glassView.layer.opacity
    // Пробовал isHidden = true, вместо opacity = 0.0 - по тестам получилось медленнее
    glassView.layer.opacity = 0.0  // Спрячься 
    targetView.layer.render(in: ctx.cgContext)  // Сфоткайся
    glassView.layer.opacity = originalAlpha  // Покажись
    
    return image.cgImage
}

Это работает, но добавляет еще больше накладных расходов и риск мерцания, если не синхронизировать с CADisplayLink идеально (у меня получилось не идеально 🫡).

Берите, пользуйтесь (Open Source)
Несмотря на все ограничения системы, результат того стоит - визуально эффект не отличить от нативного. Я оформил все наработки в готовую библиотеку LiquidGlass. Вам не придется самим возиться с Metal-шейдерами, настройкой Mipmaps и борьбой с рекурсией.
Подключайте, смотрите код, кидайте пулл-реквесты, если найдете способ оптимизировать захват экрана:
https://github.com/BarredEwe/LiquidGlass

Итог

Я добился того, чего хотел (но какой ценой?): визуально на iOS 14 эффект выглядит один в один как на iOS 26. Шейдеры + LLM творят чудеса.

Но работающего в любых случаях решения для динамического контента на публичных API у меня не получилось из-за закрытости системы.

Apple сделал амазинг в новой iOS, но не дала нам инструментов, чтобы воспроизвести это качество на старых версиях легальными методами. Мы вынуждены гонять данные по кругу GPU -> CPU -> GPU, когда они уже лежат в памяти видеокарты.


P.S. Еще больше графики и AI
Если вам интересны подобные эксперименты на стыке визуала и кода, заглядывайте ко мне в Telegram-канал
Prefire_iOS
Я там часто публикую то, что не тянет на полноценную статью, но выглядит залипательно. Например:
•❄️ Динамические снежинки на экране - еще один проект с шейдерами, который превращает рабочий стол в зимнюю сказку.
•🐱 Тамагочи с мозгами LLM - котик, который живет у вас на экране, но вместо скриптов им управляет локальная нейронка (Private LLM). С ним можно болтать, и он действительно понимает контекст.
https://t.me/prefire_ios - подписывайтесь, там уютно и технологично.

Если кто-то знает легальный способ получить доступ к Backdrop-буферу без слайд-шоу на старых iOS - пишите в комменты, буду рад!