Повторяю стекломорфизм в Android на AGSL шейдерах (лучше бы я этого не делал)
- понедельник, 16 июня 2025 г. в 00:00:07
Для тех, кто в танке
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 получится сделать что-то подобное, они как раз любят "заимствовать" всякие шутки у Эпл (но это и не плохо). Но в любом случае, пока не появится открытого опен-сурц решения для подобных стеклянных плюшек - ловить в стекломорфизме нечего.