三笔问题
The Three-Stroke Problem

原始链接: https://penpot.app/blog/the-three-stroke-problem/

在物理学中,“三体问题”描述了三个天体之间混乱且不可预测的相互作用。Penpot 团队在将渲染系统迁移到 WebGL 时,也面临着类似的挑战,他们将其称为“三描边问题”。 这种复杂性源于“描边”(边框)的表现会根据其位置(内侧、居中或外侧)及所使用的格式而有所不同。CSS 默认使用内侧描边,而 SVG 默认使用居中对齐,要在不同场景下实现一致的渲染,需要极其复杂的技术处理。 为了在基于 WebGL 的新渲染器中管理这种复杂性,该团队使用了 Skia 2D 库。通过应用特定的几何偏移、裁剪和遮罩,他们能够在复杂的路径上精确控制描边的位置。此外,他们通过顺序绘制的方式,支持在单个形状上叠加多个描边,这与传统上用于实现此类效果的 CSS“变通方法”(如 `box-shadow`)形成了鲜明对比。 最终,迁移到 WebGL 为 Penpot 提供了克服描边渲染“混乱”本质的灵活性,为实现单侧描边和描边转路径等高级设计功能铺平了道路。

```Hacker News 最新 | 过往 | 评论 | 提问 | 展示 | 招聘 | 提交 登录 三笔问题 (penpot.app) 29 点,由 elenathor 发布于 21 小时前 | 隐藏 | 过往 | 收藏 | 2 条评论 帮助 vivzkestrel 11 小时前 | 下一页 [–] - 作为一个专门讨论设计的博客,你们竟然在使用无限滚动?https://penpot.app/blog/page/2/ - 我向下翻了 10 页,想看你们的第 125 篇文章,结果页面误触刷新,砰,我所有的滚动进度全没了? - 难道你们不觉得博客应该使用分页功能,让我可以直接通过输入 URL 跳转到第 N 页吗? - 也许应该知行合一? 回复 rf15 13 小时前 | 上一页 [–] 总结:关于 Penpot 生成 SVG(据我所知甚至不是 SVG 本身)的方式及其在 WebGL 渲染器中如何改进的问题。 此外,文中引用了《三体》并以此为题,试图给文章披上一层其内容本身无法支撑的知识分子外衣。 回复 指南 | 常见问题 | 列表 | API | 安全 | 法律 | 申请 YC | 联系 搜索:```
相关文章

原文
When three bodies orbit each other, the resulting dynamical system is chaotic for most initial conditions

This is the statement of the well-known physics problem used to explain the instability and chaotic nature of three celestial bodies. The three-body problem shows that, according to Newtonian mechanics, the way these three bodies interact with one another is unpredictable and causes a rapid growth of uncertainty

Squares and circles with different stroke types applied at the same time, forming celestial objects.

It strikes me as a great analogy for some of the challenges we faced in Penpot while migrating to WebGL rendering, with a focus on what I've named "The Three-Stroke Problem". Paraphrasing the original statement, we could say this problem poses the following:

When three kinds of strokes entangle with each other, the resulting design system is chaotic for most initial conditions.

Let's start with the basics: what is a stroke?

A stroke, also referred to as a border, is a line that wraps around a layer. In design software, the stroke properties can be updated to customize its style, size, and color. ([1])

This definition is correct and precise: it's the line that surrounds and delimits a shape. What characterizes this line is that it doesn't have to be a solid line; it can have different styles (dashed, dotted), and the definition also mentions other properties such as size and color.
Here's an example of shapes in Penpot using different stroke styles. ([6])

Different and simple stroke combinations in Penpot

Strokes across formats

In CSS, the equivalent would be the border property. Its main properties can be used like this: border: <width> <style> <color>. The border-radius property defines the corner-rounding radius and is applied to the shape. There are also independent CSS properties for each border property: border-width, border-style, and border-color ([3])

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <link rel="stylesheet" href="./style.css">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Border Styles</title>
  </head>
  <body>
  <div class="box solid"></div>
  <div class="box dotted"></div>
  <div class="box dashed"></div>
  </body>
</html>
.box {
    width: 200px;
    height: 200px;
    padding: 16px;
    margin: 12px 0;
    font-family: sans-serif;
    background: #c7ff4fFF;
}

.solid {
    border: 10px solid #f76e28FF;
}

.dotted {
    border: 10px dotted #f76e28FF;
    border-radius: 50px;
}

.dashed {
    border: 10px dashed #f76e28FF;
    border-radius: 100%;
}

In SVG, the format used by Penpot's original renderer, which we're replacing with the WebGL renderer, the equivalent of these styles would be the following ([2]):

<svg width="240" height="700" viewBox="0 0 240 700" xmlns="http://www.w3.org/2000/svg">
  <!-- 1) Solid -->
  <rect
    x="20" y="20"
    width="200" height="200"
    fill="#c7ff4f"
    stroke="#f76e28"
    stroke-width="10"
  />

  <!-- 2) Dotted + border-radius: 50px -->
  <rect
    x="20" y="244"
    width="200" height="200"
    rx="50" ry="50"
    fill="#c7ff4f"
    stroke="#f76e28"
    stroke-width="10"
    stroke-linecap="round"
    stroke-dasharray="1 18"
  />

  <!-- 3) Dashed circle -->
  <circle
    cx="120" cy="568"
    r="95"
    fill="#c7ff4f"
    stroke="#f76e28"
    stroke-width="10"
    stroke-dasharray="20 12"
  />
</svg>

Enter the three strokes

There's one more relevant property to add to this definition: the type of stroke according to its position relative to the shape it delimits. And this is where we run into the three-stroke problem: inner, center, and outer stroke. ([7])

  • Inner Stroke: Sits completely inside the shape. Its width runs from the edge of the shape inward
  • Center Stroke: Half of the stroke sits inside the shape and the other half outside. Half of the defined width falls inside the shape, and the other half outside
  • Outer Stroke: Sits completely outside the shape. Its width runs from the edge of the shape outward
Different stroke styles combinations in Penpot with solid fill colors


This is where the party starts, because the paths that bring us together begin to diverge.

First of all: in CSS, the default border is of the inner type. For this reason, if we want outer or center borders, we have to "push" the borders outward or inward (using negative values) with the outline and outline-offset properties. outline is the equivalent of border but with an outer-type stroke.

For the "center" border, we'll have to move the border inward by half of the width we've used:

.outer {
  outline: 10px solid #f76e28FF;
}

.center {
  outline: 10px solid #f76e28FF;
  outline-offset: -5px;
}

On the other hand, while in CSS you need to juggle to get a centered stroke, in SVG the centered stroke is the default. To get inner and outer strokes you have to apply clips and masks:

<svg width="520" height="170" viewBox="0 0 520 170" xmlns="http://www.w3.org/2000/svg">
  <defs>
    <clipPath id="clipRect">
      <rect x="20" y="20" width="120" height="120" rx="20"/>
    </clipPath>

    <mask id="outerOnly">
      <rect width="100%" height="100%" fill="white"/>
      <!-- remove anything on/inside original shape -->
      <rect x="380" y="20" width="120" height="120" rx="20" fill="black"/>
    </mask>
  </defs>

  <!-- CENTER (default) -->
  <rect x="200" y="20" width="120" height="120" rx="20"
        fill="#c7ff4f" stroke="#f76e28" stroke-width="12"/>

  <!-- INNER (clipped centered stroke) -->
  <g clip-path="url(#clipRect)">
    <rect x="20" y="20" width="120" height="120" rx="20"
          fill="#c7ff4f" stroke="#f76e28" stroke-width="12"/>
  </g>

  <!-- OUTER (masked centered stroke) -->
  <rect x="380" y="20" width="120" height="120" rx="20"
        fill="#c7ff4f"/>
  <rect x="380" y="20" width="120" height="120" rx="20"
        fill="none" stroke="#f76e28" stroke-width="12" mask="url(#outerOnly)"/>
</svg>

How we do it in the WebGL renderer

So what are we doing in the WebGL renderer? In this case, at Penpot we decided to use the well-known Skia 2D rendering library. At a high level, Skia offers the SkPaint class ([4]), which lets you define how a shape is painted. The Paint type can be Fill, Stroke, or StrokeFill. For what we do in Penpot, we end up using a combination of the different Paints and their stroke methods ([5]). Here's how we do it in Skia:

Different kinds of strokes in Penpot combining inner, outer, and center, with different fill types
// Inner stroke: Skia strokes are always centered on the geometry,
// so we inset the rect by width/2. The stroke then spans from the
// shape edge inward -> fully inside the shape.
SkPaint stroke;
stroke.setAntiAlias(true);
stroke.setStyle(SkPaint::kStroke_Style);
stroke.setStrokeWidth(strokeWidth);
stroke.setColor(0xFFF76E28);

const SkRect innerRect = rect.makeInset(strokeWidth / 2, strokeWidth / 2);
canvas->drawRect(innerRect, stroke);
inner stroke in Skia
// Center stroke: this is Skia's native behavior. The stroke
// straddles the boundary, half inside, half outside. We draw
// directly on the shape rect, no offset needed.
SkPaint stroke;
stroke.setAntiAlias(true);
stroke.setStyle(SkPaint::kStroke_Style);
stroke.setStrokeWidth(strokeWidth);
stroke.setColor(0xFF14CECA);

canvas->drawRect(rect, stroke);
center stroke in Skia
// Outer stroke: outset the rect by width/2 so the centered stroke
// spans from the shape edge outward -> fully outside the shape.
SkPaint stroke;
stroke.setAntiAlias(true);
stroke.setStyle(SkPaint::kStroke_Style);
stroke.setStrokeWidth(strokeWidth);
stroke.setColor(0xFFFC8CFF);

const SkRect outerRect = rect.makeOutset(strokeWidth / 2, strokeWidth / 2);
canvas->drawRect(outerRect, stroke);
outer stroke in Skia

Paths: not everything is a square

So far it seems simple, or at least intuitive. However, in the design world not everything is squares. We don't live in Minecraft. That's why, once we start bringing in more complex shapes like Paths, adding Paints and drawing each shape twice is no longer enough. We have to reach for other Skia functions for the inner and outer strokes. By default, strokes in Skia are centered (as in SVG), so in that case there's nothing specific to do.

Assuming this triangle path:

  const SkPath path = SkPathBuilder()
                          .moveTo(128, 37)
                          .lineTo(226, 219)
                          .lineTo(30, 219)
                          .close()
                          .detach();

  const float strokeWidth = 10;

  SkPaint fill;
  fill.setAntiAlias(true);
  fill.setStyle(SkPaint::kFill_Style);
  fill.setColor(0xFFA5F1EE);
  canvas->drawPath(path, fill);
// Inner stroke: centered stroke at double width, clipped to the path.
SkPaint stroke;
stroke.setAntiAlias(true);
stroke.setStyle(SkPaint::kStroke_Style);
stroke.setStrokeWidth(strokeWidth * 2);
stroke.setColor(0xFF9042FF);

canvas->save();
canvas->clipPath(path, SkClipOp::kIntersect, /*antialias=*/true);
canvas->drawPath(path, stroke);
canvas->restore();
inner stroke on a path using Skia
// Center stroke: Skia's native behavior, nothing to correct.
SkPaint stroke;
stroke.setAntiAlias(true);
stroke.setStyle(SkPaint::kStroke_Style);
stroke.setStrokeWidth(strokeWidth);
stroke.setColor(0xFF14CECA);

canvas->drawPath(path, stroke);
center stroke on a path using Skia
// Outer stroke: draw a centered stroke at DOUBLE width into an
// isolated layer, then erase the shape's interior with kClear.
// Only the outside half of the stroke survives. The layer is
// essential: kClear must erase the layer, not the fill below.
SkPaint stroke;
stroke.setAntiAlias(true);
stroke.setStyle(SkPaint::kStroke_Style);
stroke.setStrokeWidth(strokeWidth * 2);
stroke.setColor(0xFFFC8CFF);

canvas->saveLayer(nullptr, nullptr);
canvas->drawPath(path, stroke);

SkPaint clear;
clear.setAntiAlias(true);
clear.setBlendMode(SkBlendMode::kClear);
canvas->drawPath(path, clear);
canvas->restore();
outer stroke on a path using Skia

Dashed and dotted styles

Depending on the stroke's style, we also have to get creative and use the appropriate SkPathEffect when dealing with dotted or dashed strokes:

SkPaint stroke;
stroke.setAntiAlias(true);
stroke.setStyle(SkPaint::kStroke_Style);
stroke.setStrokeWidth(strokeWidth);
stroke.setColor(0xFFFC8CFF);

const SkScalar intervals[] = {strokeWidth + 10, strokeWidth + 10};
stroke.setPathEffect(SkDashPathEffect::Make(intervals, 0.0f));

canvas->drawRect(rect, stroke);
dashed stroke using Skia
SkPaint stroke;
stroke.setAntiAlias(true);
stroke.setStyle(SkPaint::kStroke_Style);
stroke.setStrokeWidth(strokeWidth);
stroke.setColor(0xFFFC8CFF);

SkPath circle = SkPath::Circle(0, 0, strokeWidth / 2);
const float advance = strokeWidth + 5;  // spacing between dot centers
stroke.setPathEffect(SkPath1DPathEffect::Make(
    circle, advance, /*phase=*/0.0f, SkPath1DPathEffect::kTranslate_Style));

canvas->drawRect(rect, stroke);
dotted stroke using Skia

As we can see, what we said at the beginning of the post is coming true: there are many variables to keep track of, and we need to contain the chaos of the system.

One more variable: multiple strokes

All of that sounds good until a new variable shows up, a variable that isn't a property of the stroke, but of the shape: the number of strokes it can have. Everything seemed too good to be true, but sometimes design asks us to go a little further.

In Penpot's case, multiple borders have always been supported. After all, with SVG all you have to do is add more elements. In the WebGL renderer we do something similar: we just add as many borders as the shape has, in the specified order.

outer, center, and inner stroke in Skia
void draw(SkCanvas* canvas) {
      canvas->clear(SK_ColorWHITE);

      const SkRect rect = SkRect::MakeXYWH(48, 48, 160, 160);
      const float w = 16;
  
      SkPaint p;
      p.setAntiAlias(true);

      // Fill
      p.setStyle(SkPaint::kFill_Style);
      p.setColor(0xFFA5F1EE);
      canvas->drawRect(rect, p);

      p.setStyle(SkPaint::kStroke_Style);
      p.setStrokeWidth(w);

      // Outer stroke: outset by w/2 -> spans from shape edge outward.
      p.setColor(0xFFFC8CFF);                    // pink
      canvas->drawRect(rect.makeOutset(w / 2, w / 2), p);

      // Center stroke: on the rect itself -> half in, half out.
      // Overlaps the inner half of the outer stroke; drawn later, so it wins.
      p.setColor(0xFF14CECA);
      canvas->drawRect(rect, p);

      // Inner stroke: inset by w/2 -> spans from shape edge inward.
      // Drawn last, covers the inner half of the center stroke.
      p.setColor(0xFF9042FF);
      canvas->drawRect(rect.makeInset(w / 2, w / 2), p);
  }

As we can see, we have to draw each stroke individually (plus the fill)

Different shapes using multiple stroke styles in Penpot

In CSS, however, supporting multiple strokes on the same shape couldn't be done through any border-type property (though that may change soon!). You always use some workaround as a visual trick to add several borders. The most classic one is the box-shadow property, which does support multiple shadows. The shadow is drawn solid (with no blur), and that gives you the stroke effect:

.multi-border {
    border-radius: 20px;
    background: #A5F1EE;
    box-shadow:
      inset 0 0 0 6px #9042FF,
      0 0 0 6px #A5F1EE,
      0 0 0 12px #14CECA,
      0 0 0 18px #FC8CFFAA;
}
A CSS-square with rounded corners and different types of strokes, using the box-shadow property

As we can observe, this works for outer stroke and inner stroke too. In the latter case, using the `inset` setting does the trick. That said, it looks like we'll soon have the option to add multiple borders in CSS declaratively!

Lea Verou's post in Bluesky asking for border-color and outline-color properties

Wrapping up

We started with a system that, like three orbiting bodies, seemed destined for chaos: three kinds of strokes, three formats that each disagree about what the "default" stroke is, and a growing pile of variables on top.

The takeaway: thanks to using the WebGL render, we have far more flexibility when implementing visual elements related to borders compared to what we could do in SVG.

And we're not done yet. There's still plenty of room to keep pushing what strokes can do in Penpot, with features like stroke to path (converting a stroke into an editable path shape of its own) and individual strokes per side (setting a different stroke for each edge of a shape).

The three-stroke party continues 🎉


联系我们 contact @ memedata.com