Pull to refresh

Повторяю стекломорфизм в Android на AGSL шейдерах (лучше бы я этого не делал)

Level of difficultyMedium
Reading time8 min
Views9.3K

Для тех, кто в танке

Apple презентовали свой новый фирменный стиль. Liquid Glass - это новый материал... Красиво ли это? Спорно, конечно, а спорить я сейчас не хочу.

Всё новое - это забытое старое? Как бы да... но нет. Я прочёл десятки комментариев о том, что подобное уже было в прошивках китайских смартфонов, таких как Сяоми или чё там ещё у Китая. На самом деле то, что показали в бета версии iOS - не только не встречалось в Android нигде ранее, но и не появится в Android ближайшее время.

Если вы приглядитесь внимательно - этот стеклянный материал вообще не так прост, как это может показаться на первый взгляд. Это - не размытие. Мы видим искажения изображения под "стеклом", а на самом "стекле" видим отражения того, что находится рядом. Более того, вокруг "стекла" есть небольшой контур отражение света, который меняется в зависимости от положения устройства.

Ну ладно. Челлендж - повторить что-то подобное в Android. Не берусь говорить, что это получится так же хорошо. Да и что получится вообще хоть что-нибудь...

Анализ

Итак, попробуем проанализировать всё, что мы увидели.

Изображение... искажения... размытие... о чём вы подумали? Первое, что приходит в голову – ✨ ШЕЙДЕРЫ ✨

Окей, шейдеры. Ладно. А к чему их применять? Ну, очевидно же, к тому, что находится на экране? А нет, картинка же не статичная, на экране - не изображение. На экране - куча всего: контент, кнопки, текст, всё это движется и пользователь с этим всем взаимодействует...

А вообще на экране, обычно, находятся вьюхи. Ну и напишем какую-то свою вьюху.

К слову, я ни разу не писал шейдеров, не знаком с тем, как это работает. Так что если вам кажется, что я несу чушь - возможно я несу чушь.

Попробуем

Что она (вьюха) будет делать? Ну пускай она будет захватывать картинку под собой, применять какие-то искажения (то есть, применять шейдеры к изображению).

Так как у меня устройство с Android 15 - можно использовать AGSL шейдеры. Почитали документацию, пойдем дальше.

А как захватить то, что находится под вью? Ну я подумал, и решил - пускай у нашей вьюхи будет targetView - цель, к которой она будет применять эффекты. Так даже лучше - мы сможем не обновлять нашу вью постоянно, а будем использовать onPreDrawListener и обновлять нашу вью тогда, когда цель претерпевает изменения.

Ну давайте напишем сначала какую-то вью, которая будет рендерить targetView в bitmap и показывать его на себе.

Окей, targetView... значит пускай будет так:

fun setTargetView(view: View) {
        targetView?.viewTreeObserver?.removeOnPreDrawListener(targetLayoutListener)
        targetView = view
        view.viewTreeObserver.addOnPreDrawListener(targetLayoutListener)
    }

targetLayoutListener будет заниматься рендерингом всей той жести, которую мы придумаем. Но пока он будет заниматься просто отображением того, что происходит в targetView.

private val targetLayoutListener = ViewTreeObserver.OnPreDrawListener {
        updateBitmap()
        true
    }

  private fun updateBitmap() {
        val view = targetView ?: return
        val bmp = view.drawToBitmap(Bitmap.Config.ARGB_8888)
        targetBitmap = bmp

        val shader = runtimeShader ?: return
        val bitmapShader = BitmapShader(
            bmp,
            Shader.TileMode.CLAMP,
            Shader.TileMode.CLAMP
        )

        val targetPos = IntArray(2)
        val selfPos = IntArray(2)

        view.getLocationOnScreen(targetPos)
        getLocationOnScreen(selfPos)

        shader.setInputShader("iImage1", bitmapShader)
        shader.setFloatUniform("iImageResolution", bmp.width.toFloat(), bmp.height.toFloat())
        shader.setFloatUniform("iTargetViewPos", targetPos[0].toFloat(), targetPos[1].toFloat())
        shader.setFloatUniform("iShaderViewPos", selfPos[0].toFloat(), selfPos[1].toFloat())

        shaderPaint?.shader = shader
        invalidate()
    }

Ну и напишем простой шейдер, который будет отрисовывать то, что мы получили в bitmap:

private val shaderCode = """
        uniform shader iImage1;
uniform float2 iTargetViewPos;      // позиция targetView на экране
uniform float2 iShaderViewPos;      // позиция ShaderView на экране
uniform float2 iImageResolution;    // размер targetBitmap

half4 main(float2 fragCoord) {
    float2 globalCoord = fragCoord + iShaderViewPos - iTargetViewPos;
    float2 uv = globalCoord / iImageResolution;
    return iImage1.eval(uv * iImageResolution); // либо просто iImage1.eval(globalCoord);
}
    """.trimIndent()

Для наглядности расположим на экране ScrollView, а в нём - кучу разноцветных кнопок. Это и будет наш targetView. Нашу View расположим внутри cardview с elevation, чтобы тень отличала её от targetView.

Вот она - наша вьюшка, на нёё указывает красная стрелка
Вот она - наша вьюшка, на нёё указывает красная стрелка

Итак... вроде всё работает. На нашей вьюхе видно то, что находится под ней. Уже неплохо.

Стекло, стекло, стекло

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

Думая насчёт линзы я пришёл к чему-то такому:

Мне кажется, что это должно быть похоже на то, что показала Apple. Свет проходит через линзу. Чем ближе луч к краю линзы, тем больше будет эффект искажения.

Итак... у нашей искусственной линзы должна быть толщина, а также степень кривизны.

Попробуем модифицировать шейдер... добавим функцию, чтобы применить эффект этой линзы:

float2 applyLensDistortion(float2 fragCoord, float2 center, float2 size, float cornerRadius, float curvature, float thickness) {
    float2 delta = fragCoord - center;
    float2 local = abs(delta) - size + cornerRadius;
    float distToEdge = length(max(local, 0.0));
    float inFactor = smoothstep(cornerRadius, cornerRadius * 0.01, distToEdge);

    float2 normDelta = delta / size;
    float len = length(normDelta);
    float distortion = curvature * (1.0 - len * len*len);

    float2 offset = normalize(delta) * distortion * thickness * (1.0 - inFactor);
    return fragCoord + offset;
}

Я не силён в шейдерах. И вообще это мой первый шейдер. Поэтому - и так сойдёт.

Ну и можно добавить размытие, наверное. Я сделал его очень топорно. Можете посмеяться, я разрешаю:

half4 gaussianBlur(float2 uv, float2 resolution, float radius) {
    if (radius <= 0.0) {
        return iImage1.eval(uv * resolution);
    }
    
    half4 color = half4(0.0);
    float totalWeight = 0.0;
    
    float2 texelSize = radius / resolution;
    
    float2 offset = float2(-2.0, -2.0) * texelSize;
    float weight = exp(-8.0 / (2.0 * radius * radius * 0.25));
    color += iImage1.eval((uv + offset) * resolution) * weight;
    totalWeight += weight;
    
    offset = float2(-1.0, -2.0) * texelSize;
    weight = exp(-5.0 / (2.0 * radius * radius * 0.25));
    color += iImage1.eval((uv + offset) * resolution) * weight;
    totalWeight += weight;
    
    offset = float2(0.0, -2.0) * texelSize;
    weight = exp(-4.0 / (2.0 * radius * radius * 0.25));
    color += iImage1.eval((uv + offset) * resolution) * weight;
    totalWeight += weight;
    
    offset = float2(1.0, -2.0) * texelSize;
    weight = exp(-5.0 / (2.0 * radius * radius * 0.25));
    color += iImage1.eval((uv + offset) * resolution) * weight;
    totalWeight += weight;
    
    offset = float2(2.0, -2.0) * texelSize;
    weight = exp(-8.0 / (2.0 * radius * radius * 0.25));
    color += iImage1.eval((uv + offset) * resolution) * weight;
    totalWeight += weight;
    
    offset = float2(-2.0, -1.0) * texelSize;
    weight = exp(-5.0 / (2.0 * radius * radius * 0.25));
    color += iImage1.eval((uv + offset) * resolution) * weight;
    totalWeight += weight;
    
    offset = float2(-1.0, -1.0) * texelSize;
    weight = exp(-2.0 / (2.0 * radius * radius * 0.25));
    color += iImage1.eval((uv + offset) * resolution) * weight;
    totalWeight += weight;
    
    offset = float2(0.0, -1.0) * texelSize;
    weight = exp(-1.0 / (2.0 * radius * radius * 0.25));
    color += iImage1.eval((uv + offset) * resolution) * weight;
    totalWeight += weight;
    
    offset = float2(1.0, -1.0) * texelSize;
    weight = exp(-2.0 / (2.0 * radius * radius * 0.25));
    color += iImage1.eval((uv + offset) * resolution) * weight;
    totalWeight += weight;
    
    offset = float2(2.0, -1.0) * texelSize;
    weight = exp(-5.0 / (2.0 * radius * radius * 0.25));
    color += iImage1.eval((uv + offset) * resolution) * weight;
    totalWeight += weight;
    
    offset = float2(-2.0, 0.0) * texelSize;
    weight = exp(-4.0 / (2.0 * radius * radius * 0.25));
    color += iImage1.eval((uv + offset) * resolution) * weight;
    totalWeight += weight;
    
    offset = float2(-1.0, 0.0) * texelSize;
    weight = exp(-1.0 / (2.0 * radius * radius * 0.25));
    color += iImage1.eval((uv + offset) * resolution) * weight;
    totalWeight += weight;
    
    weight = 1.0;
    color += iImage1.eval(uv * resolution) * weight;
    totalWeight += weight;
    
    offset = float2(1.0, 0.0) * texelSize;
    weight = exp(-1.0 / (2.0 * radius * radius * 0.25));
    color += iImage1.eval((uv + offset) * resolution) * weight;
    totalWeight += weight;
    
    offset = float2(2.0, 0.0) * texelSize;
    weight = exp(-4.0 / (2.0 * radius * radius * 0.25));
    color += iImage1.eval((uv + offset) * resolution) * weight;
    totalWeight += weight;
    
    offset = float2(-2.0, 1.0) * texelSize;
    weight = exp(-5.0 / (2.0 * radius * radius * 0.25));
    color += iImage1.eval((uv + offset) * resolution) * weight;
    totalWeight += weight;
    
    offset = float2(-1.0, 1.0) * texelSize;
    weight = exp(-2.0 / (2.0 * radius * radius * 0.25));
    color += iImage1.eval((uv + offset) * resolution) * weight;
    totalWeight += weight;
    
    offset = float2(0.0, 1.0) * texelSize;
    weight = exp(-1.0 / (2.0 * radius * radius * 0.25));
    color += iImage1.eval((uv + offset) * resolution) * weight;
    totalWeight += weight;
    
    offset = float2(1.0, 1.0) * texelSize;
    weight = exp(-2.0 / (2.0 * radius * radius * 0.25));
    color += iImage1.eval((uv + offset) * resolution) * weight;
    totalWeight += weight;
    
    offset = float2(2.0, 1.0) * texelSize;
    weight = exp(-5.0 / (2.0 * radius * radius * 0.25));
    color += iImage1.eval((uv + offset) * resolution) * weight;
    totalWeight += weight;
    
    offset = float2(-2.0, 2.0) * texelSize;
    weight = exp(-8.0 / (2.0 * radius * radius * 0.25));
    color += iImage1.eval((uv + offset) * resolution) * weight;
    totalWeight += weight;
    
    offset = float2(-1.0, 2.0) * texelSize;
    weight = exp(-5.0 / (2.0 * radius * radius * 0.25));
    color += iImage1.eval((uv + offset) * resolution) * weight;
    totalWeight += weight;
    
    offset = float2(0.0, 2.0) * texelSize;
    weight = exp(-4.0 / (2.0 * radius * radius * 0.25));
    color += iImage1.eval((uv + offset) * resolution) * weight;
    totalWeight += weight;
    
    offset = float2(1.0, 2.0) * texelSize;
    weight = exp(-5.0 / (2.0 * radius * radius * 0.25));
    color += iImage1.eval((uv + offset) * resolution) * weight;
    totalWeight += weight;
    
    offset = float2(2.0, 2.0) * texelSize;
    weight = exp(-8.0 / (2.0 * radius * radius * 0.25));
    color += iImage1.eval((uv + offset) * resolution) * weight;
    totalWeight += weight;
    
    return color / totalWeight;
}

Добавим юниформы, чтобы контролировать параметры линзы и размытия, а также обновим main функцию чтобы она не обиделась:

uniform shader iImage1;
uniform float2 iImageResolution;
uniform float2 iTargetViewPos;
uniform float2 iShaderViewPos;
uniform float2 iShaderResolution;

uniform float iCurvature;
uniform float iThickness;
uniform float iCornerRadius;
uniform float iBlurRadius;

half4 main(float2 fragCoord) {
    float2 center = iShaderResolution * 0.5;
    float2 lensSize = iShaderResolution * 0.48;
    float2 distortedCoord = applyLensDistortion(
        fragCoord, center, lensSize, iCornerRadius, iCurvature, iThickness
    );
    float2 uv = getUV(distortedCoord);
    return gaussianBlur(uv, iImageResolution, iBlurRadius);
}

Мне лень показывать дальше. Да и нет смысла - там ничего интересного. Я ещё чуть-чуть модифицировал код View. Если коротко - эти параметры теперь можно задать в атрибутах в коде разметки XML.

Магия ✨

Ну и тут ниже покажу примеры того, что получилось в итоге. Играясь с параметрами линзы можно получить разные эффекты.

Стеклокнопка
Стеклокнопка
Стекло можно подкрасить для красоты
Стекло можно подкрасить для красоты

Всё хорошо? Ну... нет

  • Эти шейдеры влияют на производительность. Очень. Очень. Очень. Может быть косяк в моей реализации.

  • Этот материал нельзя применить ко всему подряд. Например, к AppBarLayout - точно нет. Он же LinearLayout. А если он не LinearLayout - прощай liftOnScroll.

  • Чтобы добавить к такому шейдеру поведение, зависящее от положение устройства в пространстве - нужно проделать много работы.

Итог и выводы

Я не знаю, как Эпл это сделали.

После проделанной мной работы я стал уважать Liquid Glass. Я не знаю, как это устроено у них, могу только предположить, что Liquid Glass - тоже шейдеры, только более продуманные. Система, которую Эпл выстраивали так долго, позволяет им это делать.

Android же пока в стороне. Большая надежда на китайцев, может быть у Xiaomi получится сделать что-то подобное, они как раз любят "заимствовать" всякие шутки у Эпл (но это и не плохо). Но в любом случае, пока не появится открытого опен-сурц решения для подобных стеклянных плюшек - ловить в стекломорфизме нечего.

Tags:
Hubs:
+52
Comments33

Articles