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

推荐订阅源

让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
人人都是产品经理
人人都是产品经理
Cisco Talos Blog
Cisco Talos Blog
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
V
V2EX
博客园 - 三生石上(FineUI控件)
Martin Fowler
Martin Fowler
WordPress大学
WordPress大学
D
Docker
S
SegmentFault 最新的问题
博客园 - 聂微东
美团技术团队
Apple Machine Learning Research
Apple Machine Learning Research
月光博客
月光博客
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
Last Week in AI
Last Week in AI
M
MIT News - Artificial intelligence
F
Fortinet All Blogs
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
The GitHub Blog
The GitHub Blog
GbyAI
GbyAI
L
LangChain Blog
Vercel News
Vercel News
博客园 - 叶小钗
MongoDB | Blog
MongoDB | Blog
Stack Overflow Blog
Stack Overflow Blog
H
Help Net Security
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
The Cloudflare Blog
Engineering at Meta
Engineering at Meta
T
Threat Research - Cisco Blogs
T
Threatpost
Scott Helme
Scott Helme
T
Tailwind CSS Blog
Latest news
Latest news
Stack Overflow Blog
Stack Overflow Blog
Blog — PlanetScale
Blog — PlanetScale
The Register - Security
The Register - Security
罗磊的独立博客
P
Proofpoint News Feed
腾讯CDC
S
Schneier on Security
雷峰网
雷峰网
A
About on SuperTechFans
T
Tenable Blog
F
Full Disclosure
Cyberwarzone
Cyberwarzone
博客园_首页
有赞技术团队
有赞技术团队
K
Kaspersky official blog

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

Россия вымирает: что говорят данные Тегирование людей на изображениях и Генерация заголовков для видеороликов Квантовые компьютеры не угроза 128-битным симметричным ключам Navidrome: поднимаем свой стриминговый сервер за один вечер Зачем мне фото- и видеоредакторы с GUI, когда есть FFmpeg? [Перевод] Астрономы разглядели галактику, возникшую всего через 800 млн лет после Большого взрыва Как решить конфликт в Git: merge, rebase, cherry-pick conflict DNSSEC validation на Go: написал свой validator и не до конца сошёл с ума Про «случайных» людей в ИТ Пять одноплатников мая 2026 года: Intel N300, RISC-V с AI и невыпущенный Raspberry Pi 6 Как я заработал 400 тысяч рублей на боте, который нарезает картинки на квадратики Простая аналитическая плафторма для 1С-ов и не только (Не)безопасный eBPF: что маркетологи забыли упомянуть об уязвимостях AMD вложит 10 млрд долларов в Тайвань ради гонки ИИ с Nvidia. Что происходит? Перепрошивка системы вознаграждения. Мой друг Никотин Никотиныч Острова и несколько личностей на одном устройстве: как мы делаем приватность частью архитектуры Как я научил Home Assistant передавать показания счётчиков и напоминать об оплате ЖКХ Как я случайно вычислил ИИ ИИ-фото нейросети для создания изображений: ТОП-14 моделей ИИ для летней фотосессии Reset — прохождение сложной машины от Tryhackme Self-Evolving Knowledge: Как взрастить senior агента Лаборатория ИИ за 200 000 ₽: как мы собрали локальный ИИ-сервер на 2× Tesla V100 Пишем движок для блога на Rust AI для PHP-разработчиков. Часть 7: Экосистема AI-агентов в PHP – от простых вызовов OpenAI до мультиагентных платформ Clean Agile: что стоит знать об Agile каждому руководителю проекта Динамические квоты и лимиты: как не завалить очередь в highload Критерии выживания и случайность — 5 Удалить фон, заменить лицо и убрать лишнее с фото: разбор лучших ИИ редакторов 2026 года Другая сторона медали Парсил zakupki.gov.ru без API — расскажу что узнал Виртуальный кулак. О боевых искусствах в играх Рентген в машине: правда или вымысел? Эволюция транзисторных архитектур чипов и переход к обратной подаче питания Промты для ИИ фотосессии в 2026: анатомия рабочего промпта и 20 готовых примеров для Nano Banana, FLUX 2 и ChatGPT Как устроена любая игра изнутри Как мы интегрировали AI агентов с T-FLEX: отказ от абстракций и самопроверка моделей Почему ваш Parallel.ForEach впустую сжигает CPU — ускоряем обработку данных до 600+ раз Искусственный интеллект в образовании: цифровые профили, аватары и персональные траектории Welder AI: виральные Shorts, Reels и TikTok на автопилоте — без лица, камеры и монтажа: С 0 до 1 000 000 просмотров OpenBSD 7.9: поддержка Wi-Fi 6, USB4 и 255 ядер. Основные изменения в ОС Командная разработка на 1С через EDT и Git: пошаговая настройка проекта Дешёвая электрогитара Rockdale Stars HT HSS От favicon до криптографии: как мы уместили 167 рабочих инструментов в одном сервисе Как создать видео из фото нейросетью в 2026: обзор моделей image-to-video и сервисов с доступом из России Zero Trust для AI-агентов: как безопасно давать LLM доступ к инструментам, данным и действиям А-12 и его родственники Как построить эпюры Q и M в многопролётной балке: следующий шаг после построения линий влияния Q и М Разбираемся в ML без воды: от базы до Attention. Часть 7: SVM и SGD Как DPI вычисляет MTProto-прокси: технический разбор детекции протоколов по сигнатурам 800 серверов, четыре названия, два брата: как Stark Industries уходил от санкций ЕС Ваш PostgreSQL болеет молча. Десяток запросов, чтобы это увидеть «Ах, как хочется вернуться… в альма-матер»: почему успешные предприниматели, бросившие занятия, решают (до)учиться Новый конкурент The Sims, демо-версия Requiem, высокие оценки LEGO Batman: Дайджест игровых новостей на 30 мая Как я заставил AI-агента писать нормальный код на Spring Обработка фото нейросетью в 2026 году: какой ИИ редактор фото выбрать под улучшение качества, реставрацию и ретушь Opus 4.8: что Anthropic дал в этом релизе и зачем это всё Я сделал Vite-плагин, который сохраняет изменения CSS прямо в исходники Самодельный контроллер для гоночного руля «Формулы-1» на базе Raspberry Pi Telegram Mini App для ресторанов: бронирования, IIKO, CRM, Grafana и Telegram API в одной системе Волшебство естественного языка и практическое применение Почему не взлетели дирижабли? Часть 23: ОКБВ, атомные мечты и проекты позднесоветской эпохи Скрытые издержки гемблинга и 18+ проектов Золото в вашем смартфоне и ноутбуке. Или про современный урбан майнинг Пять мини-ПК мая 2026 года: Panther Lake, RTX 5080 и поддержка внешних видеокарт Ralph Wiggum простыми словами: цикл в Claude Code, который не останавливается Агентные фреймворки: обещали революцию, что осталось в 2026 Самые ожидаемые эксклюзивы PlayStation в 2026–27 гг Возрождение классической игры для Unix: 20-летний процесс археологии ПО Дешёвая модерация анонимной стены: 3-слойный каскад и ROT13-джейлбрейк в проде Как СССР научил Голливуд снимать космос Как я собрал LLM-печку на 4 GPU, и на что она способна Как один зажёванный лист в принтере Xerox привел к созданию GNU Linux и всей философии Open Source Как систематизировать бизнес без бюрократии: рабочая схема для малого и среднего бизнеса Почему б / у или поддержанный ThinkPad порой лучше чем любой игровой ноут для программиста Как я стал Middle Python Developer к 22 годам и зачем пошёл учить C++ «Китайская угроза» или новые партнеры? Регистрация товарного знака для товаров из КНР под российскими брендами Как безопасно проводить сделки в USDT — опыт EscroWallet.io FIRE: когда Цифра становится ответом на вопрос, который человек не может себе задать Написание телеграм бота для проверки паролей по кибербезопасности(или же их генерация) Вайбаналитика: как я учил LLM описывать бизнес-процессы, а не имитировать их Нейросеть для ИИ-обложки: ТОП-12 ИИ моделей как быстро сделать картинку для статьи, поста или превью в 2026 году А есть ли бесплатные API нейросетей? Как Я сделал Своего Бота Телеграм Для Сканирования Портов И IP адресов Сколько стоит войти в IT в 2026 НЕТОЛОГИЯ (NETOLOGY) промокоды июнь 2026: промокод Нетология скидка 5% на все курсы Как руководителю работать с сотрудниками с РАС HackTheBox. Прохождение Mini Pro Lab Unintended Как создать видео ИИ через нейросеть: ТОП-15 моделей ИИ для видео Шасси Cisco на прокачку: как мой товарищ ударился в DIY Как сгенерировать видео для рекламного ролика: ТОП-3 лучших ИИ, которые помогут создать видео рекламу в 2026 году Требует ли мышление наличия чувств и сенсорики? От чистых мыслителей к большим языковым моделям Механика добровольной ликвидации ООО: как закрыть бизнес без перехода в банкротство и субсидиарную ответственность Ограничения вопросов на собеседовании # Bare-metal Kubernetes на 5 VM: Calico IPIP + MetalLB + GitOps — честный опыт с граблями Что мы можем получить, отказавшись от бесконечности? IT очищается от случайных людей. И это хорошо ёPRSTCON: как я навайбил конфу Как мы ускорили расчёт факторов ранжирования в поиске Ozon с помощью динамической компиляции Последний мейнтейнер Полноценный гайд по CLAUDE CODE для новичка. Обучение по CLAUDE CODE с нуля
Живые обои на Mac своими руками: Metal, окна на уровне рабочего стола и немного математики
Максим Чесников · 2026-05-31 · via Все публикации подряд на Хабре

Уровень сложностиСредний

Время на прочтение11 мин

Охват и читатели0

Туториал

Я сделал приложение NeonDrift — живые обои для macOS на основе Metal-шейдеров. Для базовой работы не нужны сторонние библиотеки, Screen Recording или Accessibility-доступ. Только AppKit, MetalKit и SwiftUI.

В статье разберу как это устроено изнутри: от трюка с уровнями окон до шейдеров и упаковки в .app. Попутно расскажу про баги, которые я поймал в процессе — растянутую плазму на Retina, крэш при первом же запуске упакованного приложения, анимацию, которая сбрасывалась при каждом переключении Space, и фризы на втором мониторе при смене Space на основном.

Главная идея статьи не в том, чтобы сделать ещё один wallpaper app, а в том, чтобы показать как на macOS можно аккуратно совместить AppKit window management, Metal render loop и SwiftUI-настройки без приватных API — и где именно этот подход начинает трещать по швам.

Живые обои NeonDrift на рабочем столе

Живые обои NeonDrift на рабочем столе


Идея: не менять обои, а нарисовать поверх рабочего стола

macOS не предоставляет официального API для живых обоев. Но есть обходной путь: создать обычное NSWindow и поместить его рядом с desktop layer — так, чтобы оно визуально работало как фон: не перехватывало клики, не появлялось в Mission Control и не конкурировало с обычными окнами.

Это не exploit: используется публичный API уровней окон — CGWindowLevelForKey(.desktopWindow). Но это всё равно window-level hack, а не официальный wallpaper API. Его нужно тестировать под конкретные версии macOS и режимы рабочего стола: Stage Manager, Spaces, Full Screen — каждый сценарий может вести себя иначе.


Шаг 1: окно на уровне рабочего стола

Вот как выглядит создание “обойного” окна для каждого монитора:

let window = NSWindow(
    contentRect: screen.frame,
    styleMask: [.borderless],
    backing: .buffered,
    defer: false,
    screen: screen
)
window.backgroundColor = .black
window.isOpaque = true
window.hasShadow = false
window.animationBehavior = .none
window.isReleasedWhenClosed = false

// Без этого окно перехватит все клики по рабочему столу
window.ignoresMouseEvents = true

// Прилипает ко всем Space, не появляется в Mission Control/Exposé
window.collectionBehavior = [
    .canJoinAllSpaces,
    .stationary,
    .ignoresCycle,
    .fullScreenAuxiliary  // помогает при переходе в/из Full Screen
]

// На практике держит окно над desktop layer, но ниже обычных окон
window.level = NSWindow.Level(
    rawValue: Int(CGWindowLevelForKey(.desktopWindow)) + 1
)

window.setFrame(screen.frame, display: true)
// Поднимаем окно внутри выбранного level без привязки к конкретному окну.
window.order(.above, relativeTo: 0)

Два момента, которые кажутся очевидными, но без которых ничего не работает:

ignoresMouseEvents = true — без этого окно перехватывает все клики по рабочему столу. Я забыл это на первой итерации и провёл несколько минут в недоумении, почему не открываются папки.

.fullScreenAuxiliary в collectionBehavior — без него окно может исчезать или вести себя нестабильно при переходе в/из Full Screen spaces. Оно помогает, но не является гарантией: поведение при возврате из full screen всё равно зависит от версии macOS.


Шаг 2: Metal pipeline и render loop

Для анимации нужен Metal. Сначала — создание устройства, command queue и pipeline:

guard let device = MTLCreateSystemDefaultDevice() else {
    throw RuntimeError("Metal недоступен на этом Mac.")
}
guard let commandQueue = device.makeCommandQueue() else {
    throw RuntimeError("Не удалось создать command queue.")
}

// Шейдеры грузятся из .metal файлов в бандле как строка исходника,
// а не из default library — потому что default library компилируется
// в момент сборки, а мы хотим грузить шейдеры динамически из ресурсов
let source = try loadShaderSource()
let library = try device.makeLibrary(source: source, options: nil)

let descriptor = MTLRenderPipelineDescriptor()
descriptor.vertexFunction   = library.makeFunction(name: "vs_main")
descriptor.fragmentFunction = library.makeFunction(name: "fs_main")
descriptor.colorAttachments[0].pixelFormat = .bgra8Unorm

let pipelineState = try device.makeRenderPipelineState(descriptor: descriptor)

MTKView получает device, кладётся в window как contentView, и отдаёт отрисовку делегату:

let view = MTKView(frame: NSRect(origin: .zero, size: screen.frame.size))
view.device = device
view.colorPixelFormat = .bgra8Unorm
view.framebufferOnly = true
view.isPaused = false
view.enableSetNeedsDisplay = false
view.preferredFramesPerSecond = 60
window.contentView = view

Рендерер реализует MTKViewDelegate. Помимо draw(in:) нужно реализовать mtkView(_:drawableSizeWillChange:) — это правильная lifecycle-точка для resize, Retina и hotplug:

func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {
    // Точка для пересчёта size-dependent ресурсов.
    // У нас ресурсы не зависят от размера — resolution передаётся
    // через uniforms каждый кадр. Но метод нужен для корректного lifecycle.
}

Весь рисунок происходит в draw(in:):

func draw(in view: MTKView) {
    guard
        let descriptor = view.currentRenderPassDescriptor,
        let drawable   = view.currentDrawable,
        let buffer     = commandQueue.makeCommandBuffer(),
        let encoder    = buffer.makeRenderCommandEncoder(descriptor: descriptor)
    else { return }

    var uniforms = Uniforms(
        time:       Float(CACurrentMediaTime() - startTime) * animationSpeed,
        resolution: SIMD2(Float(view.drawableSize.width),
                          Float(view.drawableSize.height)),
        // ... остальные параметры темы
    )

    encoder.setRenderPipelineState(pipelineState)
    encoder.setFragmentBytes(&uniforms, length: MemoryLayout<Uniforms>.stride, index: 0)
    encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3)
    encoder.endEncoding()

    buffer.present(drawable)
    buffer.commit()
}

Про drawableSize vs bounds: первую версию шейдера я написал с view.bounds.size — и получил растянутую плазму на Retina. bounds возвращает размер в points, а in.position.xy во фрагментном шейдере — физические пиксели. На Retina-дисплее разница 2×, картинка сжималась в левый нижний угол и растягивалась по viewport. view.drawableSize возвращает физические пиксели — после замены всё встало на место.

Про setFragmentBytes: удобен для небольшого uniforms-блока (у нас ~120 байт). Если добавить массивы или историю состояний — лучше перейти на MTLBuffer.


Шаг 3: шейдер — вся картинка во фрагментной функции

Вертексный шейдер тривиален — один треугольник на весь экран:

vertex VertexOut vs_main(uint vertexID [[vertex_id]]) {
    float2 positions[3] = {
        float2(-1.0, -1.0),
        float2( 3.0, -1.0),
        float2(-1.0,  3.0),
    };
    VertexOut out;
    out.position = float4(positions[vertexID], 0.0, 1.0);
    return out;
}

Вся логика картинки — во фрагментном шейдере. Пример простой плазмы:

fragment float4 fs_main(VertexOut in [[stage_in]],
                         constant Uniforms &u [[buffer(0)]]) {
    // in.position.xy — физические пиксели, u.resolution — тоже физические пиксели
    float2 uv = in.position.xy / u.resolution;
    float2 p  = uv * 2.0 - 1.0;
    p.x *= u.resolution.x / u.resolution.y;

    float t = u.time * 0.4;
    float v = sin(p.x * 3.0 + t)
            + sin(p.y * 2.5 - t * 0.7)
            + sin((p.x + p.y) * 2.0 + t * 1.3)
            + sin(length(p) * 4.0 - t * 2.0);
    v = v * 0.25 + 0.5;

    return float4(palette(v, u.palettePreset), 1.0);
}

Это классический подход для процедурной графики — так устроен Shadertoy. Вместо геометрии рисуем один треугольник, шейдер сам вычисляет цвет каждого пикселя.


Шаг 4: плавные переходы между темами

Мы передаём в шейдер два набора параметров (текущий и предыдущий) и значение transitionProgress от 0 до 1:

var themeTransitionProgress: Float {
    let elapsed = CACurrentMediaTime() - transitionStartTime
    let progress = min(max(elapsed / 0.7, 0), 1)
    return Float(1 - pow(1 - progress, 3))  // ease-out cubic
}
float3 colorA = renderTheme(params_current,  uv, u);
float3 colorB = renderTheme(params_previous, uv, u);
float3 color  = mix(colorB, colorA, u.transitionProgress);

Кросс-фейд 0.7 секунды с кубической кривой замедления. Работает между любыми двумя темами, включая переходы между family (плазма → фракталы).


Шаг 5: несколько мониторов и баг с анимацией

При подключении / отключении монитора macOS отправляет NSApplication.didChangeScreenParametersNotification:

@objc private func handleScreenConfigurationChange() {
    // Задержка нужна — без неё NSScreen.screens ещё не обновился
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
        self?.refreshDisplaysAndWallpaperWindows()
    }
}

Перед пересозданием рендереры сохраняют состояние — время старта анимации, эпоха Мандельброта и т.п. Это важно: без этого при каждом hotplug анимация начинается сначала.

Похожая проблема вылезла со Spaces. В первой версии collectionBehavior не включал .stationary, и при переключении между рабочими столами окна пересоздавались заново — анимация сбрасывалась на каждый свитч. Фикс простой, но симптом неочевидный: кажется что “обои мигают при переключении Space”.

На каждый монитор — отдельное окно и отдельный рендерер. В текущей реализации каждый рендерер сам создаёт command queue и pipeline.

Есть нерешённая проблема: при подключённых двух мониторах, если переключить Space на основном, рендерер на втором мониторе начинает заметно тормозить — FPS падает, анимация дёргается. Похоже, macOS снижает приоритет render loop для desktop-layer окон на дисплеях, которые не вовлечены в текущий Space-переход. Workaround пока не нашёл — это поведение системы, а не баг в коде рендерера. Это проще, но не оптимально: MTLDevice и pipeline state можно вынести в общий MetalContext, а на рендерер оставить только состояние конкретного экрана — command queue, uniforms, тайминги.


Шаг 6: настройки и SwiftUI UI

Панель настроек — Control Center с живым превью

Панель настроек — Control Center с живым превью

AppKit отвечает за системное поведение окон, SwiftUI — за настройки, Metal — за постоянный рендер. Для панели настроек — NavigationSplitView с боковой панелью и областью деталей. Стейт в WallpaperSettingsStoreObservableObject, данные в UserDefaults через JSON.

Предпросмотр темы прямо в настройках — это NSViewRepresentable с полноценным MTKView и отдельным рендерером, который работает независимо от “боевых” окон на рабочем столе.

Theme Gallery — живые миниатюры всех 19 тем

Theme Gallery — живые миниатюры всех 19 тем


Шаг 7: запуск при входе в систему

В macOS 13+ есть SMAppService:

try SMAppService.mainApp.register()   // включить
try SMAppService.mainApp.unregister() // выключить

Требует подписанного .app bundle. В dev-сборке через swift run не работает — только после упаковки. При первом включении macOS 14 показывает prompt в системных настройках — пользователь должен явно подтвердить.


Шаг 8: пауза при Low Power Mode

NotificationCenter.default.addObserver(
    self,
    selector: #selector(handlePowerStateChange),
    name: NSProcessInfo.powerStateDidChangeNotification,
    object: ProcessInfo.processInfo
)

private func applyPowerPolicy() {
    let shouldPause = preferences.pauseOnLowPowerMode
        && ProcessInfo.processInfo.isLowPowerModeEnabled
    for renderer in renderers.values {
        renderer.setPaused(shouldPause, reason: "Low Power Mode")
    }
}

view.isPaused = true останавливает render loop: приложение перестаёт отправлять новые кадры на GPU. Аналогично делаем при willSleepNotification / screensDidSleepNotification.


Шаг 9: упаковка в .app без Xcode

SwiftPM не создаёт .app bundle автоматически. Нужен shell-скрипт:

#!/usr/bin/env bash
set -euo pipefail

APP_NAME="NeonDrift"
BUNDLE_ID="com.yourname.neon-drift"
VERSION="0.1.0"
APP_DIR="$APP_NAME.app/Contents"

swift build -c release

mkdir -p "$APP_DIR/MacOS" "$APP_DIR/Resources"
cp ".build/release/$APP_NAME" "$APP_DIR/MacOS/"
cp -r ".build/release/${APP_NAME}_${APP_NAME}.bundle" "$APP_DIR/Resources/"

cat > "$APP_DIR/Info.plist" << EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>CFBundleExecutable</key>        <string>$APP_NAME</string>
    <key>CFBundleIdentifier</key>        <string>$BUNDLE_ID</string>
    <key>CFBundleShortVersionString</key> <string>$VERSION</string>
    <key>LSMinimumSystemVersion</key>    <string>14.0</string>
    <key>NSHighResolutionCapable</key>   <true/>
</dict>
</plist>
EOF

Bundle.module: крэш при первом запуске упакованного приложения

Первый же запуск упакованного .app закончился крэшем при старте. В консоли — assertionFailure из недр SPM. SPM-сгенерированный resource accessor рассчитывает найти бандл рядом с исполняемым, как это работает в .build/. После упаковки бандл лежит в Contents/Resources/ — accessor его не находит.

Пришлось написать собственный локатор, который проверяет оба места:

enum ShaderBundleLocator {
    static var shaderDirectoryURL: URL? {
        let bundleName = "NeonDrift_NeonDrift.bundle"

        // Упакованный .app: Contents/Resources/
        if let resourcesURL = Bundle.main.resourceURL {
            let url = resourcesURL.appendingPathComponent(bundleName)
            if let b = Bundle(url: url) { return b.resourceURL }
        }

        // SPM dev-сборка: рядом с исполняемым
        let url = Bundle.main.bundleURL.appendingPathComponent(bundleName)
        if let b = Bundle(url: url) { return b.resourceURL }

        return Bundle.main.resourceURL
    }
}

После этого и swift run, и упакованный .app работают одинаково.


Производительность

Diagnostics — FPS, статус рендера, профиль монитора

Diagnostics — FPS, статус рендера, профиль монитора

Замерял на MacBook Pro M4 Pro 24 GB, встроенный дисплей 1512×982 pt (Retina 2×, фактически 3024×1964 px), macOS Sequoia 15.4, один монитор, Activity Monitor → GPU History.

Сценарий

GPU

CPU

Плазма / Паттерны, 60 FPS

~9-17%

~8-15%

Фракталы (Мандельброт), 60 FPS

~9–23%

~8–17%

Любая тема, 30 FPS

примерно на 2-3 процента меньше

также примерно на 2-3 процента меньше

Пауза (Low Power Mode)

0%

0%

Фракталы тяжелее плазмы — больше итераций на пиксель. На два монитора нагрузка растёт почти пропорционально суммарному числу пикселей, так как работают два независимых render loop. Цифры сильно зависят от thermal state: под длительной нагрузкой MacBook может троттлить, поэтому GPU load и стабильность FPS будут меняться.


Что реально не сработало (и почему)

SceneKit / SpriteKit. Первая мысль была — взять SceneKit, добавить SCNPlane, кинуть на него шейдер. Я потратил день на это, получил рабочий прототип, потом выкинул. Не потому что SceneKit плохой — а потому что мне нужен ровно один fullscreen quad и один render pass. SceneKit тащит за собой граф сцены, менеджер ресурсов, физику. Это как ехать за хлебом на грузовике.

ScreenSaverView. Есть ScreenSaver API: ScreenSaverView, configureSheet, деплой через System Settings. Я проверил — это работает именно как заставка, не как постоянный фон. ScreenSaver деактивируется при любой активности пользователя. Не то.

Bundle.module. Описано выше. Симптом мерзкий — assertionFailure без внятного сообщения об ошибке, только адрес в стеке. Я минут 20 думал что сломал линковку.

App Store. Пробовал подготовить сборку для MAS. В моей попытке sandbox-окружение сломало ожидаемое поведение desktop-layer окна: оно либо не вставало на нужный уровень, либо вело себя нестабильно. Возможно, это решается другой конфигурацией entitlements или collectionBehavior — я не стал превращать это в отдельное расследование и пока оставил прямую дистрибуцию.


Совместимость: что я проверил

Сценарий

Результат

macOS 14, один монитор

Работает

macOS 15, один монитор

Работает

Два монитора, hotplug

Работает, анимация сохраняется

Два монитора, смена Space на основном

Фризы на втором мониторе — не решено

Mission Control

Окна не видны — как и должно быть

Переключение Spaces

Работает, анимация не сбрасывается

Full Screen app → выход

Иногда артефакт порядка окон на ~0.3 сек

Stage Manager включён

Работает, но не тестировал всесторонне

Sleep → Wake

Работает, пересоздаёт окна автоматически

Low Power Mode

Рендер паузится, возобновляется при выходе

Stage Manager протестирован только на базовых сценариях: переключение окон, Mission Control и возврат из Full Screen. Сложные комбинации — несколько дисплеев с разными Space на каждом — я не проверял.


Архитектура целиком

AppDelegate
├── refreshDisplaysAndWallpaperWindows()   — окно на каждый NSScreen
├── PlasmaRenderer (MTKViewDelegate)       — один на монитор
│   ├── init(view:)                        — device, commandQueue, pipeline
│   ├── loadShaderSource()                 — .metal из бандла → строка
│   ├── draw(in:)                          — uniforms → encoder → present
│   ├── mtkView(_:drawableSizeWillChange:) — resize/Retina/hotplug
│   └── apply(configuration:)             — тема + transition
├── WallpaperSettingsStore (ObservableObject)
│   ├── UserDefaults (JSON)                — персистентность
│   ├── SMAppService                       — login item
│   └── callbacks → AppDelegate
└── WallpaperSettingsView (SwiftUI)
    ├── NavigationSplitView
    ├── WallpaperPreviewView (NSViewRepresentable + MTKView)
    └── ConfigurationEditorCard

Production-нюансы

  • drawableSize, не bounds — иначе на Retina получите растянутую картинку в левом нижнем углу.

  • Не игнорируйте mtkView(_:drawableSizeWillChange:): даже если сейчас ресурсы не зависят от размера, это правильная точка для будущей resize/Retina/hotplug-логики.

  • FPS configurable: 30/60/120. 120 имеет смысл только на дисплеях с высокой частотой обновления; на обычных 60 Hz это просто лишняя нагрузка без видимого эффекта.

  • Паузить при Low Power Mode, sleep, и опционально — при работе от батареи.

  • После sleep currentDrawable может быть nil несколько кадров — guard в draw(in:) обязателен.

  • MTLDevice и pipeline state можно шарить между несколькими рендерерами — создание на каждый монитор это лишние ресурсы (в текущей реализации не оптимизировано).


Итог

Живые обои на macOS — это в первую очередь window-level hack: NSWindow с уровнем CGWindowLevelForKey(.desktopWindow) + 1, ignoresMouseEvents, правильный collectionBehavior. Дальше — Metal render loop поверх MTKView, фрагментный шейдер который считает цвет каждого пикселя из времени и математики, и немного AppKit-клея для реакции на смену мониторов, sleep/wake и Low Power Mode.

Весь код — около 2600 строк Swift и ~1200 строк Metal. Никаких внешних зависимостей. macOS 14+ — это ограничение конкретной реализации, не самого подхода.

Исходники: github.com/maxches99/NeonDrift