Jul 12

Liquid Glass(液态玻璃效果)实现原理续:在Android中实现液态玻璃效果GlassView

Lrdcq , 2025/07/12 02:33 , 程序 , 閱讀(3418) , Via 本站原創
书接上回https://lrdcq.com/me/read.php/165.htm,我们探索了iOS26中液态玻璃的实现,同时在末尾留了一个尾巴,说:

- 统一渲染的操作系统,除非操作系统的renderserver支持,应该无法在性能可以接受的情况下重现这个流程的。因此旧版本iOS系统与鸿蒙系统扑街。对应,Android系统利用runtimeEffect,可以至少从流程上完整重现这个过程。

因此我们来整理一下在Android中实现液态玻璃效果需要经历哪些流程。
目前的效果如下,同时相关Demo完整代码附本文末尾:



整体流程与大致涉及的技术

如上Demo的使用代码看起来如下:
        // GlassView是液态玻璃效果View,同时是一个FrameLayout
        GlassView glass = new GlassView(this);
        FrameLayout.LayoutParams layout = new FrameLayout.LayoutParams(
                FrameLayout.LayoutParams.MATCH_PARENT,
                FrameLayout.LayoutParams.WRAP_CONTENT);
        layout.setMargins(0, 300, 0, 0);
        glass.setLayoutParams(layout);
        glass.setPadding(10,10,10,10);
        frameLayout.addView(glass);

        TextView hello = new TextView(this);
        hello.setTypeface(Typeface.create("sans-serif-black", Typeface.NORMAL));
        hello.setText("23:59");
        hello.setTextSize(128);
        hello.getPaint().setFakeBoldText(true);
        hello.setLayoutParams(new FrameLayout.LayoutParams(FrameLayout.LayoutParams.WRAP_CONTENT, FrameLayout.LayoutParams.WRAP_CONTENT));
        glass.addView(hello);// 把元素放进GlassView中

        glass.setCameraDistance(getResources().getDisplayMetrics().density * 10000);
        ObjectAnimator shakeAnim = ObjectAnimator.ofFloat(
                hello,
                "rotationY",
                -20f,
                20f
        );
        shakeAnim.setDuration(5000);
        shakeAnim.setRepeatCount(ValueAnimator.INFINITE);
        shakeAnim.setRepeatMode(ValueAnimator.REVERSE);
        shakeAnim.setInterpolator(new AccelerateDecelerateInterpolator());
        shakeAnim.start(); // 做了一个动画,就是如上视频的晃来晃去的效果

根据上次整理,液态玻璃效果的流程大致为以下几步:

1. 对内容的View进行一次离屏渲染,以对内容的形状进行获取。
    - iOS中通过影子layer进行了模拟view,而在Android中,我们完全可以通过super.dispatchDraw到离屏canvas中,直接在渲染流程中进行离屏渲染。

2. sdf layer会以收集来的shape map为基础,在glass group的情况下合并多个shape map,最终得到的GPU图形渲染出sdf map。
    - 也就是说在Android中,我们需要对上一步收集到的Bitmap进行SDF生成运算,得到一个新的Bitmap。这里涉及的SDF生成算法无非就是8ssedt或者jfa(Jump Flood Algorithm)算法。
    - 8ssedt性能适中,适合在CPU中实现。
    - JFA算法适合在GPU中实现,理论上是性能更优的算法,但是在移动端场景中,JFA的多次离屏渲染管线本身也有较高的成本,需要独立斟酌。

3. backdrop layer,在正常渲染的过程中,会离屏渲染获得它背后的layer渲染结果。
    - Android中完全没有等同于backdrop能力(包括iOS和Web)的东西,如过去的讨论可能和非统一渲染架构有关。如果折中处理的话,可以类似于著名开源的BlurView的处理方式,即catch父级的内容,同样是render到一个bitmap中。

4. 当backdrop layer进入正式渲染流水线中,glassBackground shader片段会获取sdfmap,将backdrop layer最终渲染成Liquid Glass的效果。
    - 此处我们即是需要输入两个Bitmap:backdrop catch结果与sdf生成结果,同时交给shader——此处不得不用上Runtime Shader了,为了高性能运算,还可以尽可能的用上RenderNode与RenderEffect来完全交给硬件渲染承接,同时也可以利用上Android自带的Blur着色器。

下文详解这个过程

Step1. 捕获GlassView的内容

这一步相当简单,只需要这样:
    @Override
    protected void dispatchDraw(@NonNull Canvas canvas) {
        canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR);

        int sampledWidth = getWidth() / sample_scale;
        int sampledHeight = getHeight() / sample_scale;

        Bitmap offscreenBitmap = mBitmapPool.getBitmap(sampledWidth, sampledHeight);
        Canvas offscreenCanvas = new Canvas(offscreenBitmap);

        offscreenCanvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR);
        offscreenCanvas.save();
        offscreenCanvas.scale(1f / sample_scale, 1f / sample_scale);
        super.dispatchDraw(offscreenCanvas);
        offscreenCanvas.restore();
    }

1. 通过在单独的offscreenCanvas中对super.dispatchDraw的调用,便将GlassView的内容绘制到独立的bitmap中了,利用传统技术没有什么疑惑。

2. 要注意的是GlassView引入了sample_scale,通过一定采样率来缩小SDF图像的尺寸,来让SDF运算更高效。实际效果上看1/2采样是相对来说均衡的,如上视频即是这个配置。

Step2. SDF生成

通过第一步得到的offscreenBitmap,生成mSDFBitmap就是一个纯粹的算法工作了,我们斟酌之后采用了8ssedt算法在主线程中直接操作bitmap的像素数据进行计算。关键代码如下:
    private void generateSDF(Bitmap input) {
        MAX_DIST = (int) (50);
        MAX_DIST_SQ = MAX_DIST * MAX_DIST;
        final int w = input.getWidth();
        final int h = input.getHeight();

        // 从池中获取网格数据
        GridData gridData = mGridPool.getGridData(w, h);
        int[][] distSq = gridData.distSq;
        int[][] gridDx = gridData.gridDx;
        int[][] gridDy = gridData.gridDy;

        // 1. 读取像素数据
        int[] pixels = new int[w * h];
        input.getPixels(pixels, 0, w, 0, 0, w, h);

        // 2. 初始化网格 - 修正内外判断
        for (int y = 0; y < h; y++) {
            for (int x = 0; x < w; x++) {
                int alpha = (pixels[y * w + x] >> 24) & 0xFF;
                if (alpha < 0.5) { // 透明像素(边界)
                    gridDx[y][x] = 0;
                    gridDy[y][x] = 0;
                    distSq[y][x] = 0;
                } else { // 非透明像素(内部)
                    gridDx[y][x] = Integer.MAX_VALUE;
                    gridDy[y][x] = Integer.MAX_VALUE;
                    distSq[y][x] = Integer.MAX_VALUE;
                }
            }
        }

        // 3. 8SSEDT算法 - 第一遍(左上→右下)
        for (int y = 0; y < h; y++) {
            for (int x = 0; x < w; x++) {
                compare(gridDx, gridDy, distSq, x, y, -1, -1); // 左上
                compare(gridDx, gridDy, distSq, x, y, 0, -1);   // 上
                compare(gridDx, gridDy, distSq, x, y, 1, -1);   // 右上
                compare(gridDx, gridDy, distSq, x, y, -1, 0);   // 左
            }
        }

        // 4. 8SSEDT算法 - 第二遍(右下→左上)
        for (int y = h - 1; y >= 0; y--) {
            for (int x = w - 1; x >= 0; x--) {
                compare(gridDx, gridDy, distSq, x, y, 1, 0);    // 右
                compare(gridDx, gridDy, distSq, x, y, -1, 1);   // 左下
                compare(gridDx, gridDy, distSq, x, y, 0, 1);    // 下
                compare(gridDx, gridDy, distSq, x, y, 1, 1);    // 右下
            }
        }

        // 5. 获取或创建SDF Bitmap
        mSDFBitmap = mBitmapPool.getBitmap(w, h);

        // 6. 填充SDF Bitmap - 保证所有内部像素都有有效向量
        for (int y = 0; y < h; y++) {
            for (int x = 0; x < w; x++) {
                int distSqVal = distSq[y][x];
                float r; // 距离值

                // 计算归一化距离
                if (distSqVal == 0) {
                    r = 1.0f; // 边界点
                } else if (distSqVal >= MAX_DIST_SQ) {
                    r = 0.0f; // 超出最大距离
                } else {
                    r = 1.0f - (float) Math.sqrt(distSqVal) / MAX_DIST;
                }

                // 计算向量 - 确保所有内部点都有有效向量
                float g = 0.5f;
                float b = 0.5f;

                if (distSqVal > 0 && distSqVal < Integer.MAX_VALUE) {
                    int dx = gridDx[y][x];
                    int dy = gridDy[y][x];

                    // 所有非零向量都进行计算(包括短向量)
                    if (dx != 0 || dy != 0) {
                        double len = Math.sqrt(dx * dx + dy * dy);
                        g = (float) (dx / len + 1) * 0.5f;
                        b = (float) (dy / len + 1) * 0.5f;
                    }
                }

                int color = Color.argb(255,
                        (int) (r * 255),    // R通道 = 距离
                        (int) (g * 255),    // G通道 = X方向
                        (int) (b * 255)     // B通道 = Y方向
                );
                mSDFBitmap.setPixel(x, y, color);
            }
        }
    }

    private void compare(int[][] gridDx, int[][] gridDy, int[][] distSq,
                         int x, int y, int dx, int dy) {
        int w = gridDx[0].length;
        int h = gridDx.length;
        int nx = x + dx;
        int ny = y + dy;

        if (nx < 0 || ny < 0 || nx >= w || ny >= h) return;
        if (distSq[ny][nx] == Integer.MAX_VALUE) return;

        int newDx = gridDx[ny][nx] + dx;
        int newDy = gridDy[ny][nx] + dy;
        int newDistSq = newDx * newDx + newDy * newDy;

        if (newDistSq < distSq[y][x]) {
            distSq[y][x] = newDistSq;
            gridDx[y][x] = newDx;
            gridDy[y][x] = newDy;
        }
    }

1. 每次运算需要3个缓冲区,即记录每个像素点所谓“最近距离”的平方距离与坐标。(记录平方距离是尽可能避免开方运算,后续shader也会用到)

2. 和iOS的逻辑相同,我们只计算图像内的距离,图像外的数据和超过最大距离的数据结论上可以直接抛弃,空渲染结果就好。

3. 通过bitmap.getPixels获取像素和通过bitmap.setPixel设置像素是这个方案中唯一的输入输出部分。幸运的是setPixel性能非常好。或者是这一段代码虽然时间复杂度非常厉害,但是由于是计算密集型逻辑,在Android的AOT下性能依然非常OK。

4. 如Step1描述整个SDF图像是降采样的,同时考虑到抗锯齿的因素,这个计算逻辑得到的距离方向场,相对于iOS的SDF生成逻辑,会有严重的锯齿和错位现象。最终这个通过Step4的shader去抗抖动抹平,粗糙的效果看起来如下:



如果希望完全放到GPU中运算,则需要使用JFA算法:

- JFA应用到rendereffect流程中有3段shader,分别作为seed生成,每次一次jfa遍历,和最终的sdf生成。

- 其中核心jfa遍历其所需的Pass次数 直接取决于输入纹理的分辨率,以长边为计算,上面的demo依旧采用1/2采样长边720px,初始步长step = max(width, height) / 2即360,再以2的幂次递减,直到步长为1,则需要log2(720) = 9次pass。

- 如果以rendernode为基础,需要串接11次RenderEffect.createChainEffect看起来是:
int step = 360;
RenderEffect seed = RenderEffect.createRuntimeShaderEffect(seedShader, "input");
RenderEffect jfa = seed;
while (step > 0) {
    RuntimeShader jfaShaderC = jfaShader.clone();
    jfaShaderC.setFloatUniform("step", step);
    jfa = RenderEffect.createChainEffect(
        RenderEffect.createRuntimeShaderEffect(jfaShaderC, "source"),
        jfa
    );
    step /= 2;
}
sdfShader.setFloatUniform("maxDistance", maxDistance);
RenderEffect sdf = RenderEffect.createChainEffect(
    RenderEffect.createRuntimeShaderEffect(sdfShader, "source"),
    jfa
);
mRenderNode.setRenderEffect(sdf);

即通过一个循环将多次RenderEffect利用createChainEffect串起来——虽然是GPU运算,但是由于高昂的单次GPU pass,动辄十几次的jfa算法的整体成本也不低,同时最终需要将渲染结果利用HardwareRenderer从GPU中取出,作为后续计算的离屏渲染缓存结果。

Step3. Backdrop获取

Backdrop获取完全模拟各个开源的BlurView即可,即:

1. 利用getViewTreeObserver().addOnPreDrawListener去见听所有View树的改变情况

2. 同时通过我们目标覆盖物backgroundTarget.isDirty()去判断我们的目标背景View是否需要重现更新

3. 一旦需要更新,直接
Canvas bgCanvas = new Canvas(backgroundBitmap);
bgCanvas.translate(-tmpRect.left, -tmpRect.top);
backgroundTarget.draw(bgCanvas);

把目标区域draw到一个离屏缓存即可。

要注意此处和开源的blurview的不同,由于blurview的渲染结果只和Backdrop内容有关联,因此Backdrop的内容不用进行离屏缓存,可以直接上屏,使用renderNode也更合理比如:
RecordingCanvas recordingCanvas = blurNode.beginRecording();
recordingCanvas.drawRenderNode(backgroundTarget.renderNode);
applyBlur();
blurNode.endRecording();

但是对于液体玻璃来说,效果同时由sdf与backdrop决定的,存在sdf一直更新但是backdrop不变的场景,因此sdf和backdrop都不会直接上屏,而是需要render到bitmap做离屏渲染的缓存优化,因此此处用rendernode反而会比较别扭。或者说,需要结合实际场景进行优化或者切换模式可能更合理。

Step4. 液态玻璃shader运算

这一部分运算的核心是shader,和之前的iOS版本几乎没有区别,进行特殊处理的地方进行了标注:
uniform shader sdf;
uniform shader source;
uniform shader background;

uniform float2 size;
uniform float sample;

uniform float scale;

uniform float ref_height;
uniform float ref_length;
uniform float ref_border_width;
uniform float ref_exposure;

half4 getColorWithOffset(float2 coord, float2 offset) {
    half4 color = source.eval(coord + offset);

    half3 rgb = color.rgb * ref_exposure;
    return half4(rgb, color.a);
}

float _linear_map(float x, float y, float a, float b, float tsetnumber) {
    float ratio = (tsetnumber - x) / (y - x);
    return a + ratio * (b - a);
}

half4 main(float2 fragCoord) {
    float2 sdfCoord = fragCoord / sample;
    half4 sdf_c = sdf.eval(sdfCoord);
    
    // 对sdf图像进行了模糊处理
    float2 gb = float2(0.0);
    float count = 0;
    for (int x = -2; x <= 2; x++) {
        for (int y = -2; y <= 2; y++) {
            half4 sdf_n = sdf.eval(sdfCoord + float2(x, y));
            if (sdf_n.r <= 0.99999) {
                count++;
                gb.x += sdf_n.g;
                gb.y += sdf_n.b;
            }
        }
    }
    sdf_c.g = gb.x / count;
    sdf_c.b = gb.y / count;
    if (sdf_c.r >= 0.99999) {
        return background.eval(fragCoord);
    }

    float dis = (1.0 - sdf_c.r) * 50.0 * scale;
    float r_height = ref_height;
    float r_length = ref_length;

    if (dis < r_height) {

        float offsetVal = _linear_map(r_height, 0.0, r_height, r_height - r_length, dis);
        float offset = dis - offsetVal;

        float2 normal = float2(sdf_c.gb) * 2.0 - 1.0;
        float2 offset_normal = normal * offset;
        half4 result = getColorWithOffset(fragCoord, offset_normal);
        
        // 应用法线偏移,实现边缘效果
        if (dis <= ref_border_width) {
            float rate = 0.0;
            if (normal.x * normal.y > 0.0) {
                float angle = atan(abs(normal.y), abs(normal.x)) / 3.1415926 * 2;
                if (angle < 0.5) {
                    rate = angle * 2.0;
                } else {
                    rate = 2.0 - angle * 2.0;
                }
            }
            result = result * (1.0 + rate * 0.8);
        }
        return result;
    } else {
        return getColorWithOffset(fragCoord, float2(0.0));
    }
}

相对于iOS版本,实际上核心逻辑的区别只有:

1. 由于如上算法的sdf图是降采样生成的,并且没有进行抗锯齿,因此有严重的条形错位。通过一个5x5的平均采样,对sdf数据进行了模糊,以保证渲染结果的平滑。

2. 其他部分其实是对这段shader能力的补充,以实现上面的边缘高光效果与更丰富的入参数。

和iOS相同, 此处也同时利用了系统提供的高斯模糊效果与当前shader串接起来,因此shader与runtimeeffect的代码看起来如下:
        Shader background = new BitmapShader(backgroundBitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP);
        Shader sdf = new BitmapShader(mSDFBitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP);
        rs.setInputShader("sdf", sdf);
        rs.setInputShader("background", background);
        rs.setFloatUniform("size", getWidth(), getHeight());
        rs.setFloatUniform("sample", (float) sample_scale);
        rs.setFloatUniform("scale", mScale);

        rs.setFloatUniform("ref_height", ref_height);
        rs.setFloatUniform("ref_length", ref_length);
        rs.setFloatUniform("ref_border_width", ref_border_width);
        rs.setFloatUniform("ref_exposure", ref_exposure);

        mRenderNode.setPosition(0, 0, backgroundBitmap.getWidth(), backgroundBitmap.getHeight());

        Canvas renderCanvas = mRenderNode.beginRecording();
        renderCanvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR);
        renderCanvas.drawBitmap(backgroundBitmap, 0, 0, new Paint());
        mRenderNode.endRecording();

        mRenderNode.setRenderEffect(
                RenderEffect.createChainEffect(
                        RenderEffect.createRuntimeShaderEffect(rs, "source"),
                        RenderEffect.createBlurEffect(blur_radius, blur_radius,Shader.TileMode.CLAMP)
                )
        );

        canvas.drawRenderNode(mRenderNode);


最终结果

最终结果即本文开始的那段视频的效果了。这个效果是对之前iOS demo的1.5:1增量复刻,虽然原理上会更接近iOS原生液体玻璃的效果,但是目前仍然有较大问题:

1. 在每一帧都需要进行sdf生成的情况下,fps在高端机都会低至15-20fps,这是不可接受的。瓶颈仍然在sdf生成上。
    - 实际上iOS的使用场景中也会完全避免每一帧都需要进行sdf生成,如果能把这个过程优化到60fps作用,应该就满足了。

2. 在sdf即前景固定,仅滑动背景的情况下,fps增加到越40-50fps,作为高端机仍然无法接受。
    - 这里的diff是Android不具有类似于iOS与Web的渲染引擎级的backdrop能力,手动进行背景bitmap的捕获相对低效。这个问题也是blurview等开源方案的问题,仍需要大量调优才能解决,如降采样等。
    - 这个diff深入渲染架构的区别,没有可以解决的预期了。

3. 这个方案原理上和blurview一样,可以降级到renderscript解决。不过由于其中涉及的光栅化逻辑的复杂度,如果是在CPU上运行性能会更加堪忧。
    - 因此液态玻璃效果在Android上的向下兼容可以认为和blurview一致。
    - 这个说起来,和从来不做向下兼容SDK的苹果对比起来,谷歌还是是好人。
    - 这段代码继续进行完善,同时在shader里进行基础的抗锯齿处理的话,是有希望可以达到业务可用SDK的状态的。

demo核心代码作为附件上传了:
logo