← 返回博客

利用 Rust 和 GPU 以 120 FPS 渲染用户界面

2023 年 3 月 7 日


现代显示器的刷新率范围为每秒 60 到 120 帧,这意味着应用程序每帧只有 8.33 毫秒的时间将像素推送到屏幕上。这包括更新应用程序状态、布局 UI 元素,以及最终将数据写入帧缓冲区。

这是一个紧张的期限,如果您曾经使用 Electron 构建过应用程序,那么您可能会觉得这个期限几乎无法始终如一地满足。在 Atom 工作时,我们正是这种感觉:无论我们多么努力,总有一些东西阻碍我们按时交付帧。由于垃圾回收导致的随机暂停,我们就错过了一帧。一个昂贵的 DOM 重新布局,我们又错过了一帧。帧率永远不稳定,而且许多原因都超出了我们的控制范围。

然而,当我们努力对 Atom 的渲染管道进行微优化,该管道由简单的框和字形组成时,我们惊叹于计算机游戏以恒定的每秒 120 帧的速度渲染漂亮、复杂的几何图形。怎么会这样,渲染几个 <div> 比绘制一个三维、逼真的人物慢这么多?

当我们开始构建 Zed 时,我们决心创建一个代码编辑器,使其响应速度快到几乎消失。受到游戏世界的启发,我们意识到实现所需性能的唯一方法是构建我们自己的 UI 框架:GPUI。

Zed 像电子游戏一样渲染,这让我们可以在用户界面中炸开所有图层,并模拟 3D 摄像机绕着它们旋转。

GPUI:渲染

当我们开始构建 Zed 时,在 GPU 上进行任意 2D 图形渲染仍然是一个非常研究性的项目。我们尝试了 Patrick Walton 的 Pathfinder crate,但它的速度不足以达到我们的性能目标。

因此,我们退后一步,重新考虑我们试图解决的问题。虽然能够渲染任意图形的库可能很好,但事实是我们实际上并不需要它来开发 Zed。在实践中,大多数 2D 图形界面可以分解为几个基本元素:矩形、阴影、文本、图标和图像。

我们没有担心通用的图形库,而是决定专注于为我们知道需要渲染 Zed UI 的每个特定图形原语编写自定义着色器。通过在 CPU 上以数据驱动的方式描述每个原语的属性,我们可以将所有繁重的工作委派给 GPU,在那里可以并行绘制 UI 元素。

在以下各节中,我将说明 GPUI 中用于绘制每个原语的技术。

绘制矩形

朴实的矩形是图形用户界面的基本构建块。

要了解 GPUI 中绘制矩形的工作原理,我们首先需要了解有符号距离函数(简称 SDF)的概念。顾名思义,SDF 是一个函数,给定一个输入位置,它返回到某个数学定义的对象边缘的距离。随着位置越来越靠近对象,距离接近零,当步入其边界内时,距离变为负值。

圆的有符号距离函数。

已知的 SDF 列表非常广泛,这主要归功于 Inigo Quilez 在该主题上的 开创性工作。在他的网站上,您还可以找到一系列永无止境的技术,这些技术允许对 SDF 进行扭曲、组合和重复,以生成最复杂和逼真的 3D 场景。认真地说,去看看。它非常棒。

回到矩形:让我们推导出它们的 SDF。我们可以通过将我们要绘制的矩形居中在原点来简化问题。从这里,相对容易看出问题是对称的。换句话说,计算位于四个象限之一中的点的距离,相当于计算该点在任何其他三个象限中的镜像的距离。

在原点绘制矩形可以让我们使用绝对值,并且只担心正象限。

这意味着我们只需要担心矩形的右上部分。以角为参考,我们可以区分三种情况

  • 情况 1),该点既高于角又位于角的左侧。在这种情况下,点和矩形之间的最短距离由点到顶边的垂直距离给出。
  • 情况 2),该点既低于角又位于角的右侧。在这种情况下,点和矩形之间的最短距离由点到右边的水平距离给出。
  • 情况 3),该点高于角且位于角的右侧。在这种情况下,我们可以使用勾股定理来确定角和点之间的距离。

如果我们禁止距离向量假定负分量,则可以推广情况 3 以涵盖其他两种情况。

勾股定理和 max 函数的组合使我们能够确定点到矩形的最短距离。

我们刚刚概述的规则足以绘制一个简单的矩形,并且在本帖子的后面,我们将描述如何将其转换为 GPU 代码。不过,在我们开始之前,我们可以做一个简单的观察,允许扩展这些规则以计算圆角矩形的 SDF!

请注意,在上面的情况 3) 中,有无限多个点位于距离角相同的距离处。事实上,这些不仅仅是随机点,它们是描述从角开始并具有等于距离的半径的圆的点。

当我们远离直矩形时,边框开始变得更平滑。这是绘制圆角矩形的关键见解:给定一个所需的角半径,我们可以将原始矩形缩小它,计算到该点的距离,并从计算出的距离中减去角半径。

将矩形 SDF 移植到 GPU 非常直观。快速回顾一下,经典的 GPU 管道由顶点和片段着色器组成。

顶点着色器负责将任意输入数据映射到三维空间中的点,每组三个点定义一个我们要在屏幕上绘制的三角形。然后,对于顶点着色器生成的三角形内的每个像素,GPU 调用片段着色器,该着色器负责为给定像素分配颜色。

在我们的例子中,我们使用顶点着色器使用两个三角形定义我们要在屏幕上绘制的形状的边界框。我们不一定会填充此框内的每个像素。这留给片段着色器,我们将在接下来讨论它。

以下代码是用 Metal Shader Language 编写的,旨在与 实例化渲染 一起使用,以在单个绘制调用中将多个矩形绘制到屏幕上

struct RectangleFragmentInput {
    float4 position [[position]];
    float2 origin [[flat]];
    float2 size [[flat]];
    float4 background_color [[flat]];
    float corner_radius [[flat]];
};
 
vertex RectangleFragmentInput rect_vertex(
    uint unit_vertex_id [[vertex_id]],
    uint rect_id [[instance_id]],
    constant float2 *unit_vertices [[buffer(GPUIRectInputIndexVertices)]],
    constant GPUIRect *rects [[buffer(GPUIRectInputIndexRects)]],
    constant GPUIUniforms *uniforms [[buffer(GPUIRectInputIndexUniforms)]]
) {
    float2 position = unit_vertex * rect.size + rect.origin;
    // `to_device_position` translates the 2D coordinates into clip space.
    float4 device_position = to_device_position(position, viewport_size);
    return RectangleFragmentInput {
      device_position,
      rect.origin,
      rect.size,
      rect.background_color,
      rect.corner_radius
    };
}

为了确定要分配给此边界框内每个像素的颜色,片段着色器计算像素到矩形的距离,并且仅当像素位于边界内时才填充像素(即,当距离为零时)

float rect_sdf(
    float2 absolute_pixel_position,
    float2 origin,
    float2 size,
    float corner_radius
) {
    float2 half_size = size / 2.;
    float2 rect_center = origin + half_size;
 
    // Change coordinate space so that the rectangle's center is at the origin,
    // taking advantage of the problem's symmetry.
    float2 pixel_position = abs(absolute_pixel_position - rect_center);
 
    // Shrink rectangle by the corner radius.
    float2 shrunk_corner_position = half_size - corner_radius;
 
    // Determine the distance vector from the pixel to the rectangle corner,
    // disallowing negative components to simplify the three cases.
    float2 pixel_to_shrunk_corner = max(float2(0., 0.), pixel_position - shrunk_corner_position);
 
    float distance_to_shrunk_corner = length(pixel_to_shrunk_corner);
 
    // Subtract the corner radius from the calculated distance to produce a
    // rectangle having the desired size.
    float distance = distance_to_shrunk_corner - corner_radius;
 
    return distance;
}
 
fragment float4 rect_fragment(RectangleFragmentInput input [[stage_in]]) {
    float distance = rect_sdf(
        input.position.xy,
        input.origin,
        input.size,
        input.corner_radius,
    );
    if (distance > 0.0) {
        return float4(0., 0., 0., 0.);
    } else {
        return input.background_color;
    }
}

阴影

为了在 GPUI 中渲染阴影,我们采用了 Figma 联合创始人 Evan Wallace 开发的 技术。为了完整起见,我将在此处总结博客文章的内容,但绝对值得阅读原始文章。

通常,应用程序中的阴影使用高斯模糊渲染。对于每个输出像素,高斯模糊都是所有周围输入像素的加权平均的结果,分配给每个像素的权重对于更远的像素以遵循高斯曲线的方式减少。

将高斯模糊应用于 Zed 徽标。

如果我们移动到连续域,我们可以将上述过程视为输入信号(在离散情况下,即图像的像素)与高斯函数(在离散情况下,是表示高斯概率分布值的矩阵)的卷积。卷积是一种特殊的数学运算,它通过计算两个函数的乘积的积分来产生一个新函数,其中一个函数(哪个都无所谓)关于 y 轴对称。直观地说,它的工作方式就像我们将高斯曲线在整个图像上滑动,为每个像素计算一个移动的加权平均值,该平均值从高斯曲线采样以确定周围像素的权重。

高斯模糊一个有趣的方面是它们是可分离的。也就是说,可以分别沿 x 轴和 y 轴应用模糊,并且生成的输出像素与在二维中应用单个模糊相同。

对于矩形,存在一个闭合形式的解,可以在不采样相邻像素的情况下绘制其模糊版本。这是因为矩形也是可分离的,并且可以表示为两个Boxcar 函数的交集,每个维度一个

相交两个 Boxcar 函数会产生一个矩形。

高斯函数与阶跃函数的卷积等效于高斯函数的积分,从而产生误差函数(也称为 erf)。因此,生成模糊的直线矩形与分别模糊每个维度然后相交两个结果相同

float rect_shadow(float2 pixel_position, float2 origin, float2 size, float sigma) {
    float2 bottom_right = origin + size;
    float2 x_distance = float2(pixel_position.x - origin.x, pixel_position.x - bottom_right.x);
    float2 y_distance = float2(pixel_position.y - origin.y, pixel_position.y - bottom_right.y);
    float2 integral_x = 0.5 + 0.5 * erf(x_distance * (sqrt(0.5) / sigma));
    float2 integral_y = 0.5 + 0.5 * erf(y_distance * (sqrt(0.5) / sigma));
    return (integral_x.x - integral_x.y) * (integral_y.x - integral_y.y);
}

然而,对于圆角矩形与高斯函数的二维卷积,不存在像上面那样的闭合形式解,因为圆角矩形的公式不可分离。 Evan Wallace 的近似方法的巧妙之处在于沿一个轴执行闭合形式的精确卷积,然后在相反的轴上手动滑动高斯函数有限次数

float blur_along_x(float x, float y, float sigma, float corner, float2 half_size) {
    float delta = min(half_size.y - corner - abs(y), 0.);
    float curved = half_size.x - corner + sqrt(max(0., corner * corner - delta * delta));
    float2 integral = 0.5 + 0.5 * erf((x + float2(-curved, curved)) * (sqrt(0.5) / sigma));
    return integral.y - integral.x;
}
 
float shadow_rounded(float2 pixel_position, float2 origin, float2 size, float corner_radius, float sigma) {
    float2 half_size = size / 2.;
    float2 center = origin + half_size;
    float2 point = pixel_position - center;
 
    float low = point.y - half_size.y;
    float high = point.y + half_size.y;
    float start = clamp(-3. * sigma, low, high);
    float end = clamp(3. * sigma, low, high);
 
    float step = (end - start) / 4.;
    float y = start + step * 0.5;
    float alpha = 0.;
    for (int i = 0; i < 4; i++) {
        alpha += blur_along_x(point.x, point.y - y, sigma, corner_radius, half_size) * gaussian(y, sigma) * step;
        y += step;
    }
 
    return alpha;
}

文本渲染

高效地渲染字形对于像 Zed 这样文本密集的应用程序至关重要。与此同时,生成与目标操作系统外观和感觉相匹配的文本同样重要。要了解我们如何在 GPUI 中解决这两个问题,我们需要了解文本排版和字体栅格化的工作原理。

文本排版是指确定应渲染哪些字形以及应将它们放置在何处的过程,给定一些字符序列和一个字体。有多个 开源 排版 引擎,并且操作系统通常会开箱即用地提供类似的 API(例如,macOS 上的 CoreText)。通常认为排版非常昂贵,尤其是在处理本质上更难排版的语言(例如阿拉伯语或梵文)时更是如此。

关于这个问题的一个关键观察结果是,文本通常在帧之间变化不大。例如,编辑一行代码不会影响周围的行,因此再次排版这些行是不必要的昂贵。

因此,GPUI 使用操作系统的 API 来执行排版(这保证了文本看起来与其他本机应用程序一致),并维护文本-字体对到排版字形的缓存。当首次排版一段文本时,它会被插入到缓存中。如果后续帧包含相同的文本-字体对,则会重用排版的字形。反之亦然,如果文本-字体对从后续帧中消失,则会从缓存中删除。这分摊了排版的成本,并将其限制为仅在帧与帧之间变化的文本。

字体栅格化另一方面,是指将字形的矢量表示形式转换为像素的过程。有几种方法可以实现栅格化器,包括经典 CPU 栅格化器,例如操作系统提供的栅格化器(例如,macOS 上的 CoreText)或 FreeType,以及一些更近期的研究项目,主要使用 计算着色器在 GPU 上这样做(例如,PathfinderFormaVello)。

然而,如前所述,我们对 GPUI 的假设是,我们可以通过为特定图元编写着色器来实现最佳性能,而不是拥有一个能够渲染任意矢量图形的单一引擎。对于文本,我们的目标是渲染大部分静态内容,而没有与平台的本机视觉样式相匹配的交互式转换。此外,需要渲染的字形集是有限的,并且可以非常有效地缓存,因此在 CPU 上渲染实际上不会成为瓶颈。

A screenshot of a glyph atlas produced by Zed.
Zed 生成的字形图集的屏幕截图。

与文本排版一样,我们让操作系统处理字形栅格化,以便文本与其他本机应用程序完美匹配。特别是,我们仅栅格化字形的 alpha 分量(不透明度):我们稍后会讨论原因。我们实际上渲染每个单独字形最多 16 种不同的变体,以考虑子像素定位,因为 CoreText 会微妙地调整字形的抗锯齿,以使其在 X 和 Y 方向上略微移动的视觉外观。

然后,将生成的像素缓存到 图集中,这是一个存储在 GPU 上的长期存在的纹理。每个字形在图集中的位置都存储在 CPU 上,并且字形在图集中精确定位,以使用 etagere 提供的 bin-packing 算法尽可能地占用空间。

最后,使用先前计算的排版信息,将这些字形组装在一起,以形成应用程序想要渲染的原始文本。

以上操作是在单个实例化绘制调用中完成的,该调用描述了字形的目标位置及其在图集中的位置

typedef struct {
  float2 target_origin;
  float2 atlas_origin;
  float2 size;
  float4 color;
} GPUIGlyph;

请注意 GPUIGlyph 如何允许为字形指定颜色。这就是我们之前仅使用其 alpha 通道栅格化字形的原因。通过仅存储字形的不透明度,我们可以使用简单的乘法填充它,并避免为每个使用的颜色在图集中存储一个字形副本。

struct GlyphFragmentInput {
    float4 position [[position]];
    float2 atlas_position;
    float4 color [[flat]];
};
 
vertex GlyphFragmentInput glyph_vertex(
    uint unit_vertex_id [[vertex_id]],
    uint glyph_id [[instance_id]],
    constant float2 *unit_vertices [[buffer(GPUIGlyphVertexInputIndexVertices)]],
    constant GPUIGlyph *glyphs [[buffer(GPUIGlyphVertexInputIndexGlyphs)]],
    constant GPUIUniforms *uniforms [[buffer(GPUIGlyphInputIndexUniforms)]]
) {
    float2 unit_vertex = unit_vertices[unit_vertex_id];
    GPUIGlyph glyph = glyphs[glyph_id];
    float2 position = unit_vertex * glyph.size + glyph.origin;
    float4 device_position = to_device_position(position, uniforms->viewport_size);
    float2 atlas_position = (unit_vertex * glyph.size + glyph.atlas_origin) / uniforms->atlas_size;
 
    return GlyphFragmentInput {
        device_position,
        atlas_position,
        glyph.color,
    };
}
 
fragment float4 glyph_fragment(
    GlyphFragmentInput input [[stage_in]],
    texture2d<float> atlas [[ texture(GPUIGlyphFragmentInputIndexAtlas) ]]
) {
    constexpr sampler atlas_sampler(mag_filter::linear, min_filter::linear);
    float4 color = input.color;
    float4 sample = atlas.sample(atlas_sampler, input.atlas_position);
    color.a *= sample.a;
    return color;
}

有趣的是,使用字形图集组合文本的性能如何接近 GPU 的带宽,因为我们实际上是将字节从一个纹理复制到另一个纹理,并沿途执行乘法运算。没有比这更快的了。

图标和图像

GPUI 中渲染图标和图像采用与文本渲染中描述的类似技术,因此我们不会花费太多时间在此处进行介绍。与文本完全一样,SVG 图标会被解析,然后仅使用其 alpha 通道在 CPU 上栅格化为像素,以便可以着色。另一方面,图像不需要着色,因此它们在单独的纹理上上传,同时保留其颜色。

最后,使用类似于上面所示的字形着色器,将图标和图像重新组合到其目标位置。

GPUI: Element 特性

到目前为止,我们已经讨论了渲染是如何实现的底层细节。然而,在使用 GPUI 创建应用程序时,这种关注点完全被抽象掉了。相反,当用户需要创建无法通过现有元素的组合来表达的新图形功能时,他们会与 Element 特性进行交互。

pub trait Element {
    fn layout(&mut self, constraint: SizeConstraint) -> Size;
    fn paint(&mut self, origin: (f32, f32), size: Size, scene: &mut Scene);
}

GPUI 中的布局受到了 Flutter 的极大启发。具体来说,元素嵌套成树状结构,约束向下流动,大小向上流动。约束指定给定元素可以采用的最小和最大尺寸。

pub struct SizeConstraint {
    pub min: Size,
    pub max: Size,
}
 
pub struct Size {
    pub width: f32,
    pub height: f32,
}

根据元素的性质,layout 方法可以决定为其子元素生成一组新的约束,以考虑到该元素正在添加的任何额外的视觉细节。例如,如果一个元素想在其子元素周围绘制一个 1px 的边框,它应该将父元素提供的 max.widthmax.height 缩小 1px,并将缩小的约束提供给其子元素。

pub struct Border {
    child: Box<dyn Element>,
    thickness: f32,
    color: Color,
}
 
impl Element for Border {
    fn layout(&mut self, mut constraint: SizeConstraint) -> Size {
        constraint.max.x -= self.thickness;
        constraint.max.y -= self.thickness;
        let (width, height) = self.child.layout(constraint);
        Size {
            width: width + self.thickness,
            height: height + self.thickness,
        }
    }
 
    fn paint(&mut self, origin: (f32, f32), size: Size, scene: &mut Scene) {
        // ...
    }
}

一旦确定了元素的大小,就可以最终绘制元素树。绘制包括根据布局定位元素的子元素,以及绘制属于元素本身的视觉功能。在此过程结束时,所有元素都将其自己的图形组件推送到一个平台中立的 Scene 结构体,它是上面渲染部分中描述的图元的集合。

pub struct Scene {
    layers: Vec<Layer>
}
 
struct Layer {
    shadows: Vec<Shadow>,
    rectangles: Vec<Rectangle>,
    glyphs: Vec<Glyph>,
    icons: Vec<Icon>,
    image: Vec<Image>
}

渲染器在绘制图元时遵循特定的顺序。它首先绘制所有阴影,然后是所有矩形,然后是所有字形,依此类推。这可以防止一些图元被绘制在其他图元的前面:例如,矩形永远不能被渲染在字形的顶部。

然而,在某些情况下,这种行为是不受欢迎的。例如,一个应用程序可能希望将一个工具提示元素绘制在按钮的前面,因此工具提示的背景需要被渲染在按钮的文本之上。为了解决这个问题,元素可以将一个 Layer 推送到场景中,这可以确保它们的图形元素将被渲染在其父元素的顶部。

GPUI 还支持创建新的堆叠上下文,这允许以一种非常类似于 画家算法 的方式进行任意 z-index 定位。

继续上面的边框示例,paint 方法应该首先推送一个包含它想要绘制的边框的 Rectangle,然后定位子元素,使其不与新绘制的边框重叠。

impl Element for Border {
    fn layout(&mut self, mut constraint: SizeConstraint) -> Size {
        // ...
    }
 
    fn paint(&mut self, origin: (f32, f32), mut size: Size, scene: &mut Scene) {
        scene.push_rectangle(Rectangle {
            origin,
            size,
            border_color: self.color,
            border_thickness: self.thickness,
        });
 
        let (mut child_x, mut child_y) = origin;
        child_x += self.thickness;
        child_y += self.thickness;
 
        let mut child_size = size;
        child_size.width -= self.thickness;
        child_size.height -= self.thickness;
 
        self.child.paint((child_x, child_y), child_size, scene);
    }
}

GPUI 提供了多个开箱即用的元素,以产生丰富的视觉体验。有些元素只改变其子元素的位置和大小(例如,实现 flex-box 模型的 Flex),而其他元素添加了新的图形功能(例如,使用给定样式渲染一段文本的 Label)。

结论

这篇文章是对 GPUI 渲染引擎以及它如何被打包成一个 API 的快速浏览,该 API 允许封装布局和绘制。GPUI 的另一个重要作用是响应用户事件,维护应用程序状态,并将该状态转换为元素。

我们期待在未来的文章中讨论这个问题,所以请继续关注,很快就会听到更多关于它的信息!