Skip to content

Rework parley_draw API to reuse more work#542

Open
valadaptive wants to merge 2 commits intolinebender:mainfrom
valadaptive:fast-draw-api
Open

Rework parley_draw API to reuse more work#542
valadaptive wants to merge 2 commits intolinebender:mainfrom
valadaptive:fast-draw-api

Conversation

@valadaptive
Copy link
Contributor

As discussed in #509, we should be able to render multiple things (fill, stroke, underlines) from a single GlyphRunBuilder.

This PR introduces the GlyphRunRenderer, which is what the GlyphRunBuilder now actually builds. It can perform multiple rendering operations without being consumed, allowing a lot of work to be reused.

On my machine, this shows around a 5% improvement on all the end-to-end drawing benchmarks. For some reason, this includes the non-underline ones.

The snapshot tests appear different because I forgot to disable hinting when rendering underlines, which results in very subtle changes to their appearance. Since the GlyphRunRenderer comes from a single GlyphRunBuilder, it must use the same hinting setting for both the glyphs and underlines, so we need to make them match. I fixed the hinting setting and regenerated the snapshots in a separate commit--the optimization itself does not affect the snapshots at all.

};

for glyph in glyphs {
for glyph in self.glyph_iterator.clone() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this API means that the consumer needs to become implementation aware to avoid an expensive clone. It feels like a bit of a footgun.

Wouldn't passing Vec<Glyph>.into_iter() here cause an expensive clone of the underlying data?

My feeling is that we should nudge the consumer into an API that requires only a single iteration of the glyphs. Extracting their outlines, doing the hashmap lookup, etc are things that we want to minimise.

I.e., instead of creating an API that nudges consumption into re-running this loop for any decoration/fill/stroke glyph, just ask the consumer up-front what they want to render and then render that. Since this is already borrowed data, I can't help but think that the most common usage pattern will be:

let run_renderer = /* build run renderer */
run_renderer.fill_glyphs(...);
if has_underline {
   run_renderer.render_decoration(...)
}

Whereas, the "speed of light" implementation/API looks like:

let run_renderer = /* build with or without decoration */
run_renderer.render()

Since we can ensure only 1 hashmap lookup occurs per glyph

Copy link
Contributor Author

@valadaptive valadaptive Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At some level, we'll need to buffer all of the glyphs if we want to render both the fill and stroke. We can't just render the stroke and fill for one glyph, then the next, then the next, and so on. In all vector imaging models that I'm aware of, the text is treated as one "shape"--we render the stroke for all glyphs then the fill, or vice versa.

For example, this HTML:

<p id="example">The stroke of this text is red.</p>
#example {
  font-size: 3em;
  margin: 0;
  -webkit-text-stroke: 8px red;
  paint-order: stroke fill;
}

renders this:

image

Regarding the best API here, I believe we have two options for parley_draw:

  1. Add a CSS-like "text style" API, and bake every single property that affects drawing (stroke width, caps, joins, dashes, color, etc) as well as every thing that we can draw (fill, stroke, underline, underline style such as waviness, etc) into it. A single "draw call" renders everything specified as part of the style.
  2. Expose an API that lets you draw each item as you please, like I do here, but document that the glyph iterator must be cheaply cloneable (or else, make the user provide a new one each time).

Option 1 may well be faster, but it's a much bigger work item because it requires not just implementation work, but also design work--we need to think about what the style API should look like, and how to accommodate all use cases and potential backends.

That being said, if you have a good idea in your head for what this API should look like, let me know. I can implement it and benchmark it.

If we document that the glyph iterator should be cheaply cloneable, would you be OK with exposing this API for now?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At some level, we'll need to buffer all of the glyphs if we want to render both the fill and stroke. We can't just render the stroke and fill for one glyph, then the next, then the next, and so on.

Yup! Definitely! The context is that I think once we have glyph caching, a large part of this pipeline for happy path rendering will be the hashmap lookups, so minimising them will eventually become a priority.

When we need to render multiple passes to build an effect, it's probably going to be faster to do the hashmap lookup once, push it to a re-useable buffer of borrowed data, and then iterate through that buffer multiple times.

Add a CSS-like "text style" API, and bake every single property that affects drawing (stroke width, caps, joins, dashes, color, etc) as well as every thing that we can draw (fill, stroke, underline, underline style such as waviness, etc) into it. A single "draw call" renders everything specified as part of the style.

I don't think we need to define (at least) those brush properties ourselves - they can be generic and owned by the renderer.

That being said, if you have a good idea in your head for what this API should look like, let me know.

When I originally spent time to think about this, I considered something like this:

run_renderer.render(
    &[
        RenderOp::Stroke { brush: red, style: StrokeStyle { width: 8.0 }, transform: None },
        RenderOp::Fill { brush: black, transform: None },
    ],
    &mut renderer,
);

Where, the passed in iterator is an iterator of render operations:

pub enum RenderOp<B, S> {
    Fill {
        brush: B,
        /// Optional additional transform to apply to the layer
        transform: Option<Affine>,
    },
    Stroke {
        brush: B,
        style: S,
        transform: Option<Affine>,
    },
    /// A decoration (underline, strikethrough, etc).
    Decoration {
        brush: B,
        ...
    },
}

The challenge here is in the implementation - to use this API to minimise the amount of work required to construct the prepared glyphs.


If we document that the glyph iterator should be cheaply cloneable, would you be OK with exposing this API for now?

Yup, of course! While the API is experimental, I'm happy to proceed, unblock this PR, and the next and consider evolving the API another time. If we think there are bigger gains to be found elsewhere for performance, let's prioritise those!

Copy link
Contributor

@taj-p taj-p left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

Copy link
Contributor

@conor-93 conor-93 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also LGTM!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants