← 返回博客

利用 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 中用于绘制每个基元的技术。

绘制矩形

朴素的矩形是图形 UI 的基本构成元素。

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

圆的有符号距离函数。

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

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

将矩形绘制在原点,我们可以使用绝对值,只考虑正象限。

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

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

如果禁止距离向量出现负分量,则情况 3 可以推广到覆盖其他两种情况。

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

我们刚才草拟的规则足以绘制一个简单的矩形,在本文章后面,我们将描述它如何转化为 GPU 代码。不过,在此之前,我们可以做一个简单的观察,这将允许我们将这些规则扩展到计算圆角矩形的 SDF!

请注意,在上面的情况 3) 中,有无限多个点与角距离相同。事实上,这些并非随机点,它们描述的是以角为圆心,距离为半径的圆上的点。

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

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

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

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

以下代码采用 Metal 着色器语言,旨在与实例化渲染一起使用,通过一次绘制调用在屏幕上绘制多个矩形

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 轴应用,并且产生的输出像素与在两个维度上应用单一模糊的结果相同。

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

两个盒状函数的交集产生一个矩形。

高斯函数与阶跃函数的卷积等价于高斯函数的积分,这产生了误差函数(也称为 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 提供的箱式打包算法,尽可能少地占用空间。

最后,利用先前计算的整形信息,将这些字形组合在一起,形成应用程序想要渲染的原始文本片段。

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

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 图标在 CPU 上解析并栅格化为像素,仅使用其 alpha 通道,以便可以着色。另一方面,图像不需要着色,因此它们被上传到单独的纹理中,同时保留其颜色。

图标和图像最终使用类似于上面所示的字形着色器组装回其目标位置。

GPUI:Element trait

到目前为止,我们已经讨论了渲染实现的底层细节。然而,在使用 GPUI 创建应用程序时,这一问题被完全抽象化了。相反,当框架用户需要创建无法通过现有元素组合表达的新图形辅助功能时,他们会与 Element trait 进行交互。

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 方法可以决定为其子元素生成一组新的约束,以考虑元素添加的任何额外视觉细节。例如,如果一个元素想要在其子元素周围绘制一个 1 像素的边框,它应该将其父元素提供的 max.widthmax.height 缩小 1 像素,并将缩小后的约束提供给其子元素。

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 轴定位。

继续上面的边框示例,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 的一次旋风式介绍。GPUI 的另一个重要作用是响应用户事件、维护应用程序状态,并将该状态转换为元素。

我们期待在未来的文章中讨论这个问题,敬请期待更多精彩内容!


正在寻找更好的编辑器吗?

您今天就可以在 macOS、Windows 或 Linux 上试用 Zed。立即下载


我们正在招聘!

如果您对我们博客中涵盖的主题充满热情,请考虑加入我们的团队,帮助我们实现软件开发的未来。