Шейдеры отлично подходят для симуляции материалов. В обычных интерфейсах мы управляем только цветом, но эффекты могут быть революционными. Например, блюр в iOS изменил многие мобильные интерфейсы и стал частью интерфейса Apple Vision Pro.

Описать материал через шейдер сложно, потому что код совершенно другой, но создать динамичный эффект — ещё сложнее. В этой статье разберём пару примеров: переливающийся диск и нашумевший NameDrop в iOS 17.

Переливающийся материал

Золото — типичный материал, который невозможно передать одним цветом, это всегда будет градиент из желто-белого в черный. Посмотрите на платья: они отличаются не цветом, а материалом — свет под разными углами создаёт разный цвет.

CD-диск тоже переливается разными цветами в зависимости от положения света и наблюдателя. Давайте разберём, как повторить такой эффект на примере кода от Daniel Kuntz. Если вы совсем новичок в теме, то начните с первой статьи, где мы учимся рисовать градиент.

Для создания эффекта не обязательно погружаться в физику процесса, достаточно визуальной имитации. Вот общий план:

  • сделаем мягкую полоску, проходящую через центр диска;

  • наложим пару десятков таких полосок разного цвета друг на друга. В местах наложения цвет будет доходить до яркого белого, а в остальных местах будет оставаться цвет полоски;

  • привяжем положение полосок к гироскопу, чтобы диск переливался в зависимости от наклона телефона.

Первые два шага видно на скриншотах. Обратите внимание на то, как зеленый цвет левой полосы видно на правой картинке.

Делаем одну полосу для диска

Для отрисовки одной полоски пройдём четыре шага:

  • подвинем координаты из угла View в центр видимой области;

  • в качестве яркости используем значение косинуса и покажем только одну волну, которая проходит через центр диска;

  • добавим цвет полоски к общей яркости (полоска — это streak);

  • сконвертируем все из HSV в RGB.

Алгоритм в виде шейдера:

[[ stitchable ]] half4 cd(float2 position, half4 color, float2 viewSize) {
    // Move coordinates to circle center
    float2 uv = centeredRelativeUV(position, viewSize); 
    
    float minBrightness = 0.4;
    float3 streakHSV = streak(uv,
                              0.3, // color
                              20., // width
                              1 - minBrightness
                              );
    
    float4 resultColor = float4(float3(minBrightness),  
                                1.0); // Default brightness and alpha

    resultColor += HSVtoRGB(streakHSV);
    
    return half4(resultColor);
}

Теперь разберём каждый шаг по частям.

Сначала надо сдвинуть координаты в центр View. Для этого мы переводим координаты в относительные значения, чтобы они менялись от 0 до 1 — просто разделим текущую координату на размер View. И координата, и размер — это вектор из двух значений, поэтому обычная операция деления применяется к каждой координате отдельно:

  • координата X разделится на ширину,

  • координата Y разделится на высоту.

Текстурные координаты сохраняются в переменную UV — это типичное обозначение текстурных координат: X, Y и Z применяются для трёхмерных координат вершины, а координаты U и V для двухмерных координат текстуры, которая натягивается поверх трехмерной модели. 3D в нашем примере нет, а вот обозначение UV мы можем использовать.

Затем мы сдвигаем координаты в центр, вычитая из каждой по 0.5, потому что изначально координаты начинаются в левом верхнем углу. После сдвига координата (0, 0) окажется в центре, а значение координат будут меняться от -0.5 до +0.5.

float2 centeredRelativeUV(float2 position, float2 viewSize) {
    // Relative texture coordinates with values from 0 to 1
    float2 uv = position / viewSize; 

    // Move origin to center
    uv -= .5; 
    return uv;
}

Генерацию полоски разберём подробней и разделим на два шага: сгенерируем волну и отрежем от неё середину.

Для генерации волны возьмём Y-координату, умножим её на ширину полосы и возьмём косинус от получившегося значения. Косинус нужен, потому что его нулевое значение даёт единицу, а значит, что яркость будет максимальная в центре окружности. Width будет регулировать шаг косинуса, тем самым влияя на ширину полосы.

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

В конце собираем итоговый цвет из входящего параметра color и посчитанного нами Brightness.

float3 streak(float2 uv, float angle, float3 color, float width, float maxBrightness) {
    // Create waves
    float streak = cos(uv.y * width); // make waves

    // limit higher value by 0.6
    // for addition to default 0.4
    float centralStreak = clamp(streak, 0., maxBrightness); 
                                                            
    
    // Use value as brightness
    return float3(color.x,          // H
                  color.y,          // S
                  centralStreak);   // V
}

Получится вот такой повторяющий паттерн. Мы применили его к Y-координате, поэтому он повторяется вертикально.

Отрежем центральную часть, потому что все три полоски нам не нужны. Буквально будем смотреть, что координата выходит за диапазон от -33% до 33%, но с привязкой к передаваемой ширине полосы. В итоге умножаем цвет полосок на mask, т.е. определяем, будет у нас какой-то цвет на выходе или нет.

float3 streak(float2 uv, float angle, float3 color, float width, float maxBrightness) {
    ...
    
    // Get only center waves
    float limit = 3 / width;
    float mask = 0.0;
    if (uvY >= -limit && uvY <= limit) { // [-0.125; +0.125]
        mask = 1.0; // show only single wave near 0
    }

    // limit higher value by 0.6
    // for addition to default 0.4
    float centralStreak = clamp(streak * mask, 
								0., 
								maxBrightness); 
    ...
}

После маскирования останется одна мягкая полоса.

Сузим полосу к центру. Для этого мы можем рассчитать расстояние между координатами U и V с помощью функции length (как гипотенузу по теореме Пифагора). Получится, что чем ближе значения к центру, тем они меньше (потому что мы сдвинули координаты и значения в центре равны нулю) и тем менее яркий получится цвет. А чем ближе к краю, тем ярче. Самое яркое значение будет на центральной линии по краям при X = 0 и Y = 0.5, где длина тоже будет равна 0.5 (корень из 0^2 + 0.5^2). Всегда опирайтесь на краевые значения, чтобы лучше понимать, что вы хотите от кода.

float3 streak(float2 uv, float3 color, float width, float maxBrightness) {
    float diameter = length(uv); // higher value on the edge
    
    float uvY = uv.y; // 0 to 1 with rotation, no need for X
    
    // Create waves
    float streak = cos(uvY * width) // wake waves
                   * diameter; // brighter on the edge
		...
}

В итоге мы получаем мягкую полоску (она мягкая по высоте из-за косинуса) и более яркую к краям (из-за расчёта расстояния между координатами).

Вращаем полоску

Для полного эффекта переливания нужно привязать угол полосы к гироскопу и наложить сразу несколько полос. Для начала научим нашу полосу реагировать на вращение в зависимости от входного угла. Для этого используем матрицу поворота и применим её к координатам:

float2x2 Rotate(float a) {
    float x=cos(a), y=sin(a);
    return float2x2(x, -y, y, x);
}

float3 streak(float2 uv, float angle, float3 color, float width, float maxBrightness) {
    float diameter = length(uv); // higher value on the edge
    
    float2 rotUV = uv * Rotate(angle);
    float uvY = rotUV.y; // 0 to 1 with rotation, no need for X
		...
}

Общий код для расчёта одной мягкой вращающейся полоски:

float3 streak(float2 uv, float angle, float3 color, float width, float maxBrightness) {
    float diameter = length(uv); // higher value on the edge
    
    float2 rotUV = uv * Rotate(angle);
    float uvY = rotUV.y; // 0 to 1 with rotation, no need for X
    
    // Create waves
    float streak = cos(uvY * width) // wake waves
                 * diameter; // brighter on the edge
    
    // Get only center waves
    float limit = 1/(width/3.);
    float mask = 0.0;
    if (uvY >= -limit && uvY <= limit) { // [-0.125; +0.125]
        mask = 1.0; // show only single wave near 0
    }

    // limit higher value by 0.6
    // for addition to default 0.4
    float centralStreak = clamp(streak * mask, 0., maxBrightness); 
    
    // Use value as brightness
    return float3(color.x,          // H
                  color.y,          // S
                  centralStreak);   // V
}

Делаем несколько полос и накладываем их друг на друга

Основная работа позади: мы посчитали всё, что нужно, — осталось добавить несколько полос и наложить их друг на друга. Код может показать сложным, но на самом деле всё просто:

  • мы берём 25 полосок:

for (float i = 0; i < 25; i++) {

  • задаём им разную ширину:

float width = clamp((sin(i+1)+1) * 100, 10., 180.); // [10, 180]

  • задаём произвольный предварительный цвет (streakColor):

float hue = sin(i*8.11)+1.0; // 220 is max radian, hue in [0, 2]

  • задаём угол, под которым должна быть наша полоска. Угол будет зависеть от внешнего значения — accel:

float angle = (9*i+sin(accel*i*3));

  • считаем саму полосу (это подробно разобрали раньше):

float3 streakHSV = streak(uv, angle, streakColor, width, 1 - minBrightness);

  • добавляем цвет от этой полосы к текущему пикселю. Важно правильно понять порядок действий: к каждому пикселю по-отдельности мы считаем насколько он попадает в эти 25 полос и какой вклад каждой полосы в этот конкретный пиксель:

float o = saturate(1 - (width / 180.0)) * 0.9; // [0, 0.9]
resultColor += HSVtoRGB(streakHSV) * o;

Все это посыпано разными коэффициентами, но разбираться в каждом отдельно довольно долго — мы и не будем.

[[ stitchable ]] half4 cd(float2 position, half4 color, float2 viewSize, float accel) {
    float2 uv = centeredRelativeUV(position, viewSize); // Move coordinates to circle center
    
    float minBrightness = 0.4;
    float4 resultColor = float4(float3(minBrightness), 1.0); // Default brightness and alpha
    
    for (float i = 0; i < 25; i++) {
        float width = clamp((sin(i+1)+1) * 100, 10., 180.); // [10, 180]

        float hue = sin(i*8.11)+1.0; // 220 is max radian, hue in [0, 2]
        float3 streakColor = saturate(float3(hue,
	                                           0.7,  // saturation
	                                           0.5)); // brightness
        
        float angle = (9*i+sin(accel*i*3));
        float3 streakHSV = streak(uv, angle, streakColor, width, 1 - minBrightness);
        
        float o = saturate(1 - (width / 180.0)) * 0.9; // [0, 0.9]
        resultColor += HSVtoRGB(streakHSV) * o;
    }

    return half4(resultColor);
}

В качестве параметра accel можно взять значения от гироскопа телефона или от координаты касания и при каждом изменении пересчитывать положение полосок для каждого пикселя. Получится анимированный интерактивный круговой градиент. Проверить работу можно прямо в SwiftUI Canvas.

Репозиторий с примером

Эффект, как у NameDrop в iOS 17

Если код выше вам показался понятным, то можете самостоятельно попробовать разобраться в эффекте NameDrop.

Несколько комментариев, которые могут помочь:

  • эффект состоит из двух частей: заблюривания и взрыва, каждый ровно по 1 секунде;

  • время изменяется от 0.01 до 2 секунд с шагом в 0.01;

  • это шейдер, который умеет брать цвета соседних пикселей. Для этого используется функция layer.sample: мы рассчитываем координаты для смещения, берём значения другого пикселя и выводим как значение текущего. Так появляется ощущение волны;

  • функция mix линейно смешивает две координаты с добавлением коэффициента.

  • функция smoothStep позволяет изменять значение в диапазоне не линейно, а не плавно;

    Подробнее про функции можно почитать в спецификации по Metal.

airdrop.metal
//
//  airdrop.metal
//  Airdrop Demo
//
//  Created by Daniel Kuntz on 7/30/23.
//

#include <metal_stdlib>
#include <SwiftUI/SwiftUI_Metal.h>
using namespace metal;

[[ stitchable ]] half4 airdrop(float2 position, SwiftUI::Layer layer, float t, float2 viewSize) {
    float2 position_yflip = float2(position.x, viewSize.y - position.y);
    float uv_y_dynamic_island_offset = 0.46;

    float t2 = pow(t, 2);
    float t3 = pow(t, 3);

    // Normalized pixel coordinates (from 0 to 1)
    float2 uv = position_yflip / viewSize;
    float2 uv_stretch = float2(uv.x+((uv.x-0.5)*pow(uv.y,6)*t3*0.1), uv.y * (uv.y * pow((1-(t2*0.01)), 8.0)) + (1-uv.y) * uv.y);
    uv_stretch = mix(uv, uv_stretch, smoothstep(1.1, 1.0, t));
    float4 color = float4(layer.sample(uv_stretch * viewSize));

    float2 bang_offset = float2(0.0);
    float bang_d = 0.0;
    if (t >= 1.0) {
        float aT = t - 1.0;
        float2 uv2 = uv;
        uv2 -= 0.5;
        uv2.x *= viewSize.x / viewSize.y;

        float2 uv_bang = float2(uv2.x, uv2.y);
        float2 uv_bang_origin = float2(uv_bang.x, uv_bang.y-uv_y_dynamic_island_offset);
        bang_d = (aT*0.16)/length(uv_bang_origin);
        bang_d = smoothstep(0.09, 0.05, bang_d) * smoothstep(0.04, 0.07, bang_d) * (uv.y+0.05);
        bang_offset = float2(-8.0*bang_d*uv2.x, -4.0*bang_d*(uv2.y-0.4))*0.1;

        float bang_d2 = ((aT-0.085) * 0.14)/length(uv_bang_origin);
        bang_d2 = smoothstep(0.09, 0.05, bang_d2) * smoothstep(0.04, 0.07, bang_d2) * (uv.y+0.05);
        bang_offset += float2(-8.0*bang_d2*uv2.x, -4.0*bang_d2*(uv2.y-0.4))*-0.02;
    }

    float2 uv_stretch_bang = uv_stretch+bang_offset;
    color = float4(layer.sample(uv_stretch_bang * viewSize));
    color += bang_d*500.0 * smoothstep(1.05, 1.1, t);

    float Pi = 6.28318530718 * 2;
    float Directions = 60.0;
    float Quality = 10.0;
    float Radius = t2*0.1 * pow(uv.y, 6.0) * 0.5;
    Radius *= smoothstep(1.3, 0.9, t);
    Radius += bang_d*0.05;
    // Blur calculations
    for( float d=0.0; d<Pi; d+=Pi/Directions)
    {
        for(float i=1.0/Quality; i<=1.0; i+=1.0/Quality)
        {
            float2 blurPos = (uv_stretch_bang + float2(cos(d),sin(d))*Radius*i);
            color += float4(layer.sample(blurPos*viewSize));
        }
    }
    color /= Quality * Directions;

    uv -= 0.5;
    uv.x *= viewSize.x / viewSize.y;

    float2 lighten_uv = float2(uv.x*0.65, uv.y - t + 0.5);
    float d = smoothstep(0, 0.6, 0.1/length(lighten_uv)-uv_y_dynamic_island_offset)*0.25;
    float t_smooth = smoothstep(0.0, 0.3, t);
    d *= t_smooth;
    color = color + float4(color.r*d, color.g*d, 0.0, 1.0); // yellow blob

    float2 lighten2_uv = float2(uv.x*0.4, uv.y-uv_y_dynamic_island_offset);
    float d2 = smoothstep(0, 0.5, pow(1-length(lighten2_uv), 28))*0.5;
    float t2_smooth = smoothstep(0.0, 1.0, t2)*1.;
    d2 *= t2_smooth;
    d2 *= smoothstep(1.13, 1.0, t);
    color = float4(color.rgb*(1-d2), 1.0) + float4(float3(d2), 1.0); // white blob

    return half4(color);
}

Content.View.swift
//
//  ContentView.swift
//  Airdrop Demo
//
//  Created by Daniel Kuntz on 7/30/23.
//

import SwiftUI

struct ContentView: View {

    @State private var timer: Timer?
    @State private var t: Float = 0.0

    private let shaderFunction = ShaderFunction(library: .default, name: "airdrop")

    var body: some View {
        VStack {
            Image("mick")
                .resizable()
                .aspectRatio(contentMode: .fill)
                .scaleEffect(x: 1.0, y: -1.0)
                .layerEffect(
                    Shader(function: shaderFunction,
                           arguments: [
                            .float(t),
                            .float2(Float(UIScreen.main.bounds.width),
                                    Float(UIScreen.main.bounds.height))
                           ]), maxSampleOffset: CGSize(width: 800.0, height: 800.0)
                )
        }
        .ignoresSafeArea()
        .onAppear {
            timer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true, block: { _ in
                t = (t + 0.01).truncatingRemainder(dividingBy: 2.0)
            })
        }
    }
}

#Preview {
    ContentView()
}

Если вы и с этим примером разобрались, то можете попробовать повторить эффект шторки в ванной или переворот книги в iBooks. Если получится — покажите код, а то я пока только разборы писать научился :D

Интеграция в UIKit

SwiftUI и iOS 17 — это хорошо, но как быть обычным разработчикам, которые поддерживают UIKit и более старые версии iOS? Всё просто: нужно сделать интеграцию через MTKView, он давно с нами. Единственное, что сделали в iOS 17 — упростили вызов и срезали часть возможностей, чтобы не париться с 3D и применять это все к 2D-интерфейсу.

Сама тема тянет на отдельную статью, но начать вы можете тут:

Примеры

Репозиторий с кодом из статьи

А на Android тоже так можно?

Ага. Как создать анимированные шейдеры в Jetpack Compose


Подписывайтесь на канал Dodo Mobile — скоро будем разбираться с многопоточностью, одновременным доступом и зачем нужны акторы в Swift.

Комментарии (1)


  1. heos_spb
    26.09.2023 12:09

    CD-диск

    К чему эти полумеры? Пишите уже сразу "Компактный CD-диск".