Jun 21

从0逆向探索iOS26 Liquid Glass(液态玻璃效果)实现原理

Lrdcq , 2025/06/21 04:45 , 程序 , 閱讀(884) , Via 本站原創
每次有新的GUI框架推出了一个新的GUI效果,当然要逆向探索一下这个效果的实现原理,来明确:

1. 这个效果的整体实现流程,性能消耗,与优化路径与空间。
2. 这个效果在其他操作系统上/旧版本操作系统上是否可以对等或者降级的重现。

iOS26的Liquid Glass效果(液态玻璃效果)确实令人纠结,上手后大家都觉得又酷炫又卡,有些人觉得是GUI效果的全新上限,有些人觉得是纯粹的吃力不讨好的花活。抛开主观判断,这个效果确实值得对以上两个问题做判断。
当然作为成熟的iOS GUI开发者,了解苹果的尿性,虽然苹果是纯闭源的,看到这个效果和暴露的API之后就已经会有一些大致的判断了:

1. 这个效果和过去的毛玻璃效果看起来几乎是一样的东西,甚至UIKit API也是走的UIVisualEffectView,那么很有可能UIGlassEffect(Liquid Glass)的实现流程和UIBlurEffect(毛玻璃)几乎一模一样。

2. 我们知道iOS GUI是统一渲染架构,显然Liquid Glass效果是一个shader类效果,RenderServer中实现的。

3. 同时我们知道不同于毛玻璃本质上是简单的高斯模糊,实现Liquid Glass效果会涉及到边缘识别等更复杂的图形学技巧,其中必然涉及到多个texture的转化与处理,并不是一个简单的filter effect(Apply 一段 shader)可以实现的,我们逆向coreanimatoin.renderserver时可以一探一二。

阅读备注:
1. 全文以UIKit API为例子切入,iOS的GUI框架的背后均为统一渲染的coreanimation,因此SwiftUI一致。
2. 涉及的知识点比较杂,有不细致之处评论区讨论。
3. 本文涉及的几乎所有iOS26符号均为iOS私有/内部类,要在线上使用要记得混淆和反射调用。


获取Liquid Glass的输入:背景信息与绘制轮廓

點擊在新視窗中瀏覽此圖片


以上是将一个200x200的矩形View,进行x/y轴分别进行22.5度3D旋转后得到的一个Liquid Glass玻璃效果。肉眼可见的和毛玻璃一样,我们要得到这个效果至少要做和毛玻璃一样的事:捕获当前View背后的渲染结果。同时在当前图像的边缘进行一定的像素扭曲效果。

我们知道毛玻璃效果的核心元素和流程是这样的:

點擊在新視窗中瀏覽此圖片


1. UIVisualEffectView的实现View拥有CABackdropLayer,同时设置了一堆CAFilter,其中最关键的即高斯模糊。
2. 实际运作的实现都是在renderserver侧:
3. rrenderserver中由于CABackdropLayer的存在会对这个layer背后的内容进行一次离屏渲染,捕获后作为layer的内容,即上文的“捕获当前View背后的渲染结果”。
4. 对于layer上的filter,会在renderserver执行GPU流水线的时候,串一些特定的shader处理逻辑,比如毛玻璃引用了高斯模糊的处理。

对应的布局信息是这样的:
點擊在新視窗中瀏覽此圖片

而当我们尝试打印Liquid Glass的信息是,我们注意到UIVisualEffectView既没有child实现,layer也没有被替换,并且UIVisualEffectView也没有别的childView,苹果刻意隐藏了它的实现。不过这也难不倒我们,打印整个View的其他属性,和实际layer树的信息,我们很快就注意到:

1. GlassEffect的核心类是一个苹果的私有类_UIViewGlass:

@interface _UIViewGlass : NSObject

@property (nonatomic, readonly) NSInteger variant;
@property (nonatomic, readonly) NSInteger size;
@property (nonatomic, assign) NSInteger identifier;
@property (nonatomic) CGFloat smoothness;
@property (nonatomic, strong) UIColor *tintColor;
@property (nonatomic, strong) UIColor *controlTintColor;
@property (nonatomic, copy) NSString *subvariant;
@property (nonatomic, assign) BOOL contentLensing;
@property (nonatomic, assign) BOOL highlightsDisplayAngle;
@property (nonatomic, assign) BOOL excludingPlatter;
@property (nonatomic, assign) BOOL excludingForeground;
@property (nonatomic, assign) BOOL excludingShadow;
@property (nonatomic, assign) BOOL excludingControlLensing;
@property (nonatomic, assign) BOOL excludingControlDisplacement;
@property (nonatomic, assign) BOOL flexible;
@property (nonatomic, assign) NSInteger _flexVariant;
@property (nonatomic, assign) BOOL boostWhitePoint;
@property (nonatomic, assign) BOOL allowsGrouping;
@property (nonatomic, copy) NSString *backdropGroupName;

- (id)init;
- (id)initWithVariant:(NSInteger)variant;
- (id)initWithVariant:(NSInteger)variant size:(NSInteger)size;
- (id)initWithVariant:(NSInteger)variant size:(NSInteger)size smoothness:(CGFloat)smoothness;
- (id)initWithVariant:(NSInteger)variant size:(NSInteger)size smoothness:(CGFloat)smoothness state:(NSInteger)state;
- (id)initWithVariant:(NSInteger)variant size:(NSInteger)size smoothness:(CGFloat)smoothness subdued:(BOOL)subdued;
- (id)initWithVariant:(NSInteger)variant size:(NSInteger)size state:(NSInteger)state;
- (id)initWithVariant:(NSInteger)variant smoothness:(CGFloat)smoothness;
- (id)initWithVariant:(NSInteger)variant smoothness:(CGFloat)smoothness state:(NSInteger)state;
- (id)initWithVariant:(NSInteger)variant state:(NSInteger)state;
......
@end

@interface UIGlassEffect ()

+ (instancetype)effectWithGlass:(_UIViewGlass *)glass;
- (_UIViewGlass *)glass;

@end

_UIViewGlass是UIGlassEffect内部实际承载config的对象,上面的属性也就是实际glass效果可以控制的属性。当我们调用UIGlassEffect创建效果时,本质上会为UIGlassEffect创建一个默认的glass效果。

2. 令人惊讶的是UIGlassEffect是通过私有属性直接挂载在UIView上的。因此UIVisualEffectView上才确实没有特殊的信息。

@interface UIView ()

@property (nonatomic, strong, nullable, setter=_setGlassEffect:) UIGlassEffect *_glassEffect;

@end

仔细想想也符合预期,苹果历来的设计上UIVisualEffectView只是打掩护,glass效果对所有view都适用也是合理的选择。因此从对象关系上,是:

點擊在新視窗中瀏覽此圖片


尝试自定义了一个StarView(刻意的layerClass = CAShapeLayer以定位用户感知到的layer),同时按照以上方式塞了一个[UIGlassEffect new],此时我们看到的StarView的整个layer树变成了这个样子:
點擊在新視窗中瀏覽此圖片

- 我们的rootlayer被替换为了叫_UIMultiLayer的新引入的类,而用户的rootlayer则变成其中的一个childlayer
- 在其他结构中,我们看到了我们心心念的CABackdropLayer,它应该就是实际承载效果的layer了。

因此我们可以确定Liquid Glass的backdrop实现依然是CABackdropLayer

打印出CABackdropLayer的信息
點擊在新視窗中瀏覽此圖片

我们可以看到它拥有新增的CAFilter glassBackground,我们知道CAFilter对应着一段shader pipeline流程,因此可见CABackdropLayer确实就是Liquid Glass效果最终的出口。

- 其他layer中,最显眼的是CASDFLayer,由于也是本次新增的layer。其中既然是SDF,当然是指的Signed Distance Field有向距离场layer,当然它是获取边缘的关键环节,下文详解。
- CASDFLayer上有一个关键属性是CASDFEffect *effect,用来指向CASDFLayer的输出内容
- 而CASDFLayer的数据来源看起来是child中的CASDFElementLayer,如果是多个View合并的Liquid Glass效果,则存在多个CASDFElementLayer。详细打印这个类的信息:
點擊在新視窗中瀏覽此圖片

  这个类具有match-bounds, match-position, match-corner-radius, match-corner-radii, match-corner-curve, match-hidden这6个CAMatchXXXAnimation动画CAMatchProperty/MoveAnimation仍然是苹果私有类的一员,他们的作用是将一个layer和另一个layer的一个属性绑定在一起,观察这些Animation的source,均指向了我们实际StarView的rootLayer即用户的View。
  
因此Liquid Glass效果实际是通过CAMatchXXXAnimation机制,在renderserver同步捕获用户View的大小/位置/圆角信息/显示状态,来得到目标View的绘制轮廓信息,再提供给SDF

我们可以绘制出从原始View到CABackdropLayer的整个流程:

1. 我们拥有原始的View,和相关childView。
2. 我们为原始的View设置了_glassEffect属性,即开启了Liquid Glass效果。
3. 系统创建了CABackdropLayer,其中包含CASDFLayer,再其中包含CASDFElementLayer。
4. CASDFElementLayer映射了原始View的形状信息,CASDFLayer将其childlayer的形状处理为了SDF,CABackdropLayer携带的glassBackground shader,消费backdrop图像与SDF图像。

點擊在新視窗中瀏覽此圖片

让轮廓与边缘信息可消费:SDF的生成

上文来回提到SDF非常关键,那么为什么要让边缘和轮廓信息可以消费,需要SDF(Signed Distance Field)?
我们来观察Liquid Glass的效果。Liquid Glass看起来花里胡哨的,但是除了模糊和颜色覆盖,大致上可以分为三类:

點擊在新視窗中瀏覽此圖片

1. 边缘缩小:在图像的边缘区域,向外映射了更多的像素,得到了一个看起来像是凹透镜的效果。
2. 边缘放大:在图像的边缘区域,向外映射了更少的像素,得到了凸透镜的效果。
3. 边缘反射:在图像的边缘区域,反射的边缘内部的像素,得到了这个看起来很奇幻也最瞎的效果,苹果实际GUI上使用得最多的即这个配置。

如果在以上二维空间上很难想象,假设有一个一维像素序列,以0点为图像边缘,-10到0是图像外,0到20是图像内,同时图像边缘0-10的区域发生了扭曲,则以上三种图像扭曲效果看起来如下:

點擊在新視窗中瀏覽此圖片

这个计算看起来就简单了——本质上是(0, 扭曲边缘)到(某个位置,扭曲边缘)的线性映射,根据“某个位置”的不同得到了以上三种不同的效果。而这个线性映射的入参数即是:当前像素距离图像边缘的距离

那么,对于一个View,或者对于一个任意图像来说,如何知道一个像素点距离最近的边缘有多远呢?正好

SDF即使记录一个像素点到边缘的距离的图像

谷歌出任意一个SDF的说明看起来如下:

點擊在新視窗中瀏覽此圖片


- 右侧即左侧图像的SDF图像,右侧的颜色值0-1,记录的是那个像素点,距离边缘的距离-50px到50px。即SDF图像是0.5的位置,即左侧图像的边缘。

- SDF非常适合进行非矢量字体的存储,如上图像,如果我取SDF = 0.2的位置作为边界,这个图像就胖了一圈即“加粗”,取SDF = 0.8作为边界,则扣除了一个边缘即“变细”

- 当然,在Liquid Glass的计算中,Liquid Glass的计算本质上也就是和“加粗”“变细”一类的,用SDF储存距离自然很合适,同时考虑到Liquid Glass计算只用考虑“图像内”,因此完全可以设计为颜色值0-1对于距离边缘0px到100px这样的映射。

回到Liquid Glass的2D空间中,在一维空间中,我们只需要距离边缘的距离即可,但是在二维空间中,如果我要向边缘的某个距离取一个像素,我还需要的是当前像素点距离边缘的方向。

当然,在上面逻辑生成SDF的距离信息时,我们当然也能知道距离边缘的朝向,假设是一个向量(x, y)。如果我们将这个向量归一化(即调整为长度为1的向量),参考法线贴图的形式,我们完全可以也将这个向量信息记录到图像中。

- 那么就把距离信息储存到RGB的R通道中。
- 距离边缘的朝向信息的向量(x, y),记录到RGB的G / B通道中。

即Liquid Glass使用的SDF图像同时记录了距离边缘的朝向信息

这样得到的一张图像就是满足Liquid Glass效果计算的SDF图像了。

假设我们的UIView是这个样子的(即上文用到的例子):

點擊在新視窗中瀏覽此圖片


那么按照如上文的逻辑,它的SDF图像和他们的RGB通道看起来是这个样子:
點擊在新視窗中瀏覽此圖片

- R通道,边缘信息,0-1对应了距离边缘50px-0px
- G通道,边缘向量信息,0-1对应了(x,y)中x的-1到1
- B通道,边缘向量信息,0-1对应了(x,y)中y的-1到1

回到iOS工程中,我们是否可以验证这个过程呢?

1. 由于我们有明确的CASDFLayer,虽然SDF计算显然是一个renderserver的过程,我们无法获得其计算信息,但是我们可以将CASDFLayer硬生生的贴到当前界面上以看到到底是什么东西。以上这个View通过这个方式贴出来的效果如下:

點擊在新視窗中瀏覽此圖片


这个图像显然是SDF的数据强行渲染到GUI上存在信息丢失,除去作为SDF的核心距离信息完全没有展示出之外,GB通道的向量信息显然丢失了一半(如G通道即x轴分量,x<0的情况green均为0,不合理)。

2. 通过逆向coreanimation的符号,我们注意到以下私有类:

@interface CASDFGeneratorRequest : NSObject
//...
@end

@interface CASDFGenerator : NSObject

- (CGImageRef)generateSDFWithRequest:(CASDFGeneratorRequest *)request forImage:(CGImageRef)image;

@end

由于统一渲染的缘故,按理说他们只应该在renderserver中执行,不过App的client侧正也包含了以上符号,因此我们可以尝试调整CASDFGeneratorRequest的各个参数尝试一番——结果很乐观,CASDFGeneratorRequest的默认配置即我们想象的那样,上图中实际举例子的那张SDF图即经过调整后CASDFGenerator输出得到的图像。

  - 同时执行这段代码的时候,会注意到对于稍微大的图像,这个方法的执行耗时会相当离谱。不过正常情况下,对于一个View的背景形状大概率不会发生改变,因此只是执行一次性能可以接受。但是如果一个View的形状会一直发生改变,甚至包含动画(如3D变换动画),使用Liquid Glass会不断进行SDF生成,实测会卡爆。
    - 换句话说,除去技术必要性,SDF图像本质上是一个当前View的边缘信息的预处理和缓存数据,如果边缘信息确实会一直改变,缓存就没意义了。

  - 通过符号调用排查,这个方法最终会调用到renderserver侧的_CA::OGL::SDFNode::apply(float, CA::OGL::Surface**, float*)方法进行离屏渲染,由于在_CA(coreanimation)的OGL(opengl library)中,必然SDF计算也是交给GPU完成了,那么大概率是一个8SSEDT算法或者JumpFloodAlgorithm算法,除了用GPU加速,实时SDF计算几乎没有捷径可以走。

  - 另外,可以注意到的是,CASDFGeneratorRequest提供了一个方法requestForEffect:(CASDFEffect *)effect和CASDFEffect关联上了。因此实际流程确实和预期一致,CASDFLayer->CASDFEffect->CASDFGeneratorRequest->CASDFGenerator->GPU这样的调用流程。

生成玻璃效果:最终消费轮廓信息的Shader

在第二部分的讨论中其实已经把和shader,即CAFilter glassBackground的核心逻辑讨论得相当清楚了。实际排查glassBackground的入参数,虽然有一大堆控制模糊 / 曝光等信息的参数,但是核心参数确实只有2个:

- inputInnerRefractionHeight: 折射高度,>=0,即上文提到的扭曲开始的高度
- inputInnerRefractionAmount:折射数量,即上文提到的扭曲的距离

这两个参数全权决定了Liquid Glass的核心效果。既然逻辑如此清晰了,我们通过CIKernel的shader重现这个效果

1. 整个shader的核心入参只有4个:backdrop的背景图像sample,sdf图像sample,扭曲高度,和扭曲距离

/*
source:背景图像
sdf:sdf图像
position:辅助参数:决定sdf图像在背景图像中的位置
scale:辅助参数:屏幕scale,用于计算
ref_height:扭曲高度
ref_length:扭曲距离
*/
float4 glassProc(coreimage::sampler source, coreimage::sampler sdf, float2 position, float scale, float ref_height, float ref_length) {
    //...
}

2. 解码出各项数据,当然,关键的是当前像素原始图像数据rgba和sdf的rgba,同时根据sdf的rgba,解码出当前像素距离边缘的距离与朝向

float4 base_color = sample(source, samplerCoord(source));
float4 sdf_color = sample(sdf, (samplerCoord(source) * samplerSize(source) - position * scale) / samplerSize(sdf));
float distance = (1.0 - sdf_color.r) * 50.0 * scale; // 从sdf解码距离,0-1映射50px-0px
float2 normal = sdf_color.gb * 2.0 - 1.0; //向量解码

3. 对distance根据入参进行线性映射,得到,实际要取的像素点的偏差距离

// 线性映射,从(x, y)映射到(a,b),与输入参数testnumber
float _linear_map(float x, float y, float a, float b, float testnumber) {
    float ratio = (testnumber - x) / (y - x);
    return a + ratio * (b - a);
}

ref_height = ref_height * scale;
ref_length = ref_length * scale;
float offset = distance - _linear_map(ref_height, 0, ref_height, ref_height - ref_length ,distance);

4. 根据offset,和边缘朝向normal,去获取偏移后的像素点并返回

// 归一化的向量 x 移动距离,就是实际要偏移的像素offset
float2 offset_normal = normal * offset;

base_color = sample(source, samplerCoord(source) + offset_normal / samplerSize(source));
return base_color;

以上代码便是实现Liquid Glass的核心代码了。

当然,要实现更好的效果,综合实际苹果提供的效果,适当的高斯模糊和曝光提升和适当取值也是挺重要的附加因素:

1. 曝光效果的提升(高斯模糊 = 8px):

點擊在新視窗中瀏覽此圖片

2. 高斯模糊的提升(曝光=1.5):

點擊在新視窗中瀏覽此圖片

3. 当然,再之上同时叠加一个其他颜色效果,以满足更丰富的需求也是常有的事儿。苹果的Liquid Glass效果本质上在_UIViewGlass提供了多种预设的效果组合,能满足绝大部分苹果内部标准化的诉求。

CIKernel实现完整的Demo参看文章末尾附件。

总结:整个流程与回答一开始的问题

通过以上的讨论,排除一些边边角角,我们可以通过一张图拉出Liquid Glass效果的完整渲染流程:

點擊在新視窗中瀏覽此圖片

1. 当一个View的_glassEffect被设置,会通过match animation,为这个view创建影子layer即sdf element。

2. sdf element会进行一次离屏渲染,以得到对应view的shape map。这次之所以不是直接对目标view的render node进行离屏渲染,除开降低离屏渲染成本之外,也是因为目前对App提供的Liquid Glass的能力,仅支持矩形+圆角,以上信息足够了。

3. sdf layer会以收集来的shape map为基础,在glass group的情况下合并多个shape map,最终得到的GPU图形渲染出sdf map。

4. backdrop layer,在正常渲染的过程中,会离屏渲染获得它背后的layer渲染结果。

5. 当backdrop layer进入正式渲染流水线中,glassBackground shader片段会获取sdfmap,将backdrop layer最终渲染成Liquid Glass的效果。

Liquid Glass这个效果的整体实现流程,性能消耗,与优化路径与空间?

整个过程进行了3次独立GPU会话,当然sdf map本身是一个缓存机制,如果除开sdf map的产出流程,Liquid Glass的渲染流程与性能,应该几乎和毛玻璃相当。如果Liquid Glass没有开启模糊效果,性能甚至会高于毛玻璃(毕竟高斯模糊真的很耗性能)。

1. 在当前提供的能力,和用户适当的使用下,Liquid Glass是一个相当高效的技术方案,不会有比毛玻璃更差的性能表现。

  - 如果有一天,苹果对三方应用提供更复杂的shape map能力,即简单的sdf element无法表示view,需要进行完整的离屏渲染,首次离屏渲染的性能会差很多。(苹果目前部分一方应用确实在使用这个流程)
  - 如果用户的目标View的形状一直变化,同样会带来非常差的性能表现。
  - 由于sdf图中的所有关键区域的像素数据都是平滑可导的,sdf生成的整个流程降低分辨率,sample的利用线性插值机制来补全数据,也能带来收益。
  - 相关优化的思路也可以用在目前C端较为复杂的动效的程序化实现上,可以在内存 / 资源占用 / 实时性能上做到更好的平衡。

2. 既然整个流程涉及到大量的离屏渲染,和毛玻璃一样,尽量避免大面积的特效区域对性能总是有益的。

3. 整体的高性能表现得益于统一渲染的一揽子解决方案,因此如上的CIKernel方案是相对低效的,无法做的实时渲染。

Liquid Glass这个效果在其他操作系统上/旧版本操作系统上是否可以对等或者降级的重现?

同样由于统一渲染的原因,除了性能问题,统一渲染的安全性管理,即renderserver进程向用户进程传递数据是受严格管控的,特别是backdrop这样的数据。

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

2. 但是对应的,整个渲染流程涉及的技术点都常见且不复杂(说到底每一个技术点的代码量都百行级别的),因此像如上Demo那样,利用既有机制,进行效果的静态重现,或者应对一些低刷新率的场景,确实是可行的。
  - 原理上也可以接受未来UI/UE添加相关这种讨厌但是不可避免的元素。



logo