Skip to content

Initial anyrender implementation - #12576

Open
tronical wants to merge 40 commits into
masterfrom
simon/vello2
Open

Initial anyrender implementation#12576
tronical wants to merge 40 commits into
masterfrom
simon/vello2

Conversation

@tronical

@tronical tronical commented Jul 22, 2026

Copy link
Copy Markdown
Member

This PR introduces an anyrender based item renderer, along with screenshot tests driven by the serialise any render backend (json output) and vello_cpu (pixel output).

This is the core renderer and plumbing for testing. The frontend is not part of this PR, i.e. the wgpu/softbuffer/kms/winit integration is not part of this PR.

The bulk of the diff are the tests, the renderer itself isn't that much :)

@tronical
tronical force-pushed the simon/vello2 branch 5 times, most recently from 4ec7a00 to 342b767 Compare July 22, 2026 11:15
@tronical
tronical force-pushed the simon/vello2 branch 2 times, most recently from 1afdbbf to b7d06c3 Compare July 23, 2026 18:58
@tronical
tronical marked this pull request as ready for review July 23, 2026 18:59
tronical added 21 commits July 23, 2026 20:23
New internal (unpublished) crate that will render Slint scenes through
the anyrender abstraction (https://github.com/DioxusLabs/anyrender),
allowing any anyrender backend - vello, vello_cpu, etc. - to be used
as a Slint renderer.

This commit adds the crate manifest, workspace wiring, and the
SlintWindowRenderer trait: the seam concrete backends implement on top
of anyrender::WindowRenderer to add the fallible render/resize entry
points Slint needs.
An ItemRenderer implementation generic over any anyrender::PaintScene,
mapping Slint geometry and brushes to peniko/kurbo:

- (Border-)rectangles with the CSS inside-border model: strokes are
  centered on the path, so rect and radius are inset by half the border
  width; translucent borders fill the full outer rectangle so the
  background shows through.
- Solid colors and linear/radial/conic gradients. Solid window
  backgrounds are skipped here; they become the frame's base color.
- Rectangular clipping via anyrender clip layers, with the running
  clip rect tracked logically for culling; opacity via alpha layers.
- The usual save/restore state stack with translate/rotate/scale
  tracked as a kurbo::Affine.

Text, images, paths, and box shadows are stubbed with todo!() and
follow in subsequent commits.
Implement draw_text, draw_text_input, and draw_string by delegating
shaping and positioning to i_slint_core::textlayout::sharedparley and
implementing its GlyphRenderer trait: positioned glyph runs are fed to
PaintScene::draw_glyphs with a peniko fill or stroke brush, and
selection/cursor rectangles land in fill_rectangle with optional
rounded corners and outline.
Load Slint images into peniko::ImageData: embedded images are converted
(RGB8 expanded to RGBA8) and re-registered in Slint's image cache as
BackendStorage so the conversion happens once; SVGs are rasterized at
the fitted target size. Rendering supports nine-slice, tiling via
Extend::Repeat brush transforms, source clips, alignment, and
image-rendering quality. Colorization isolates the image in a
compositing group and applies the colorize brush through a SrcIn layer.

draw_cached_pixmap re-uploads on every call for now - this renderer
keeps no persistent per-item graphics cache.
Paths convert the fitted lyon events to a kurbo::BezPath and fill
(honoring the fill rule) and/or stroke with cap and join mapping.

Drop shadows use anyrender's blurred rounded-rectangle primitive with
the CSS convention of a Gaussian standard deviation of half the blur
radius; zero blur degenerates to a plain rounded-rect fill. The
primitive only supports a uniform corner radius, so per-corner radii
are approximated by their average. Inset shadows are not supported
yet.
The Slint Renderer (RendererSealed) implementation driving any backend
that implements SlintWindowRenderer. Rendering reads the window item's
background: solid colors become the backend's per-frame base color
instead of a fill command, everything else is drawn by the item
renderer. render_with_options() takes a fixed rotation/translation and
a post-render callback for the linuxkms use case (screen rotation,
software mouse cursor).

Text measurement and cursor placement delegate to the shared parley
layout; font registration goes through the fontique collection.
take_snapshot() routes through SlintWindowRenderer::slint_take_snapshot
so backends with CPU-readable targets can support it.
A SlintWindowRenderer that records the emitted drawing commands into an
anyrender::recording::Scene instead of rasterizing. No platform
dependencies; this is the backend used by the screenshot test driver to
snapshot the command stream, and the recorded scene can be replayed
into any real backend via PaintScene::append_scene.
New opt-in `anyrender` feature for test-driver-screenshots: each
screenshot case is rendered through AnyrenderItemRenderer into a
RecordingWindowRenderer and the recorded command stream is serialized
(via anyrender_serialize) to pretty-printed JSON and compared against a
golden in references/anyrender/. SLINT_UPDATE_TESTS=1 regenerates the
goldens.

Fonts are pinned with configure_test_fonts() so glyph runs are
deterministic across machines. On a mismatch, both the golden and the
current command stream are rasterized through the wgpu-free
anyrender_vello_cpu backend into /tmp PNGs for visual diffing, and a
line diff is included in the failure message.

Float literals in the serialized stream - JSON numbers as well as
coordinates inside the SVG path strings - are quantized to four decimal
places when writing and comparing goldens: kurbo's rounded-rectangle
arc conversion is not ULP-stable across architectures, so
full-precision goldens would differ between x86-64 and aarch64.
Generated with SLINT_UPDATE_TESTS=1:
cargo test -p test-driver-screenshots --no-default-features --features anyrender
Replays a case's recorded command stream through the production
anyrender_vello_cpu pipeline and writes the result as a PNG, for
manually verifying that the captured stream is faithful by comparing
against the existing skia/software references:

cargo run -p test-driver-screenshots --features anyrender \
    --bin replay-anyrender -- text/text
The solid window background never enters the command stream - backends
paint it as the per-frame base color - so recordings alone can't
reproduce a full frame. Store the last frame's base color alongside
the scene so tests can composite it when rasterizing a recording.
Render each case's recorded command stream through anyrender_vello_cpu
and compare pixels against references/vello_cpu/<case>.png using the
existing compare_images machinery (SLINT_CREATE_SCREENSHOTS=1 creates
references).

The rasterization composites the recorded base color underneath the
command stream, since solid window backgrounds never enter the stream.

vello_cpu's output was verified to be bit-identical between
linux/x86_64 and macos/arm64 for all current cases - the entire
pipeline (parley/harfrust shaping, skrifa outlines, vello_cpu
rasterization) is pure Rust with pinned fonts, so unlike the skia
references a single reference set serves all platforms.
Generated with SLINT_CREATE_SCREENSHOTS=1:
cargo test -p test-driver-screenshots --no-default-features --features anyrender vello_cpu_
Inset shadows were previously skipped. Render them as the border box
filled with the shadow color minus the blurred interior rectangle,
using the linearity of the Gaussian: blur(complement of rect) =
1 - blur(rect). Concretely: clip to the border box (which also
isolates the compositing), fill with the shadow color, then punch out
the blurred interior - inset by spread, translated by the offset -
through a DestOut layer at full strength.

Output closely matches the skia renderer's reference for
basic/inner-shadow: shadow direction and band intensities agree within
~5/255; the residual difference (max 37/255 in 0.14% of pixels) is the
two renderers' blur falloff.

Update the inner-shadow command-stream golden and vello_cpu reference
accordingly.
RadialGradientBrush gained explicit center/radius support (with
bounding-box defaults); the brush conversion still hardcoded the
bounding-box center and diagonal/2 radius. Use
center_or_default_scaled()/radius_or_default_scaled() like the skia
renderer does.

The radial rows of basic/radial-gradient-explicit-center now match the
skia reference; the remaining divergence in that case is the conic row,
addressed separately.
Sweep gradients natively start at 3 o'clock (east) while Slint's conic
gradients put 0deg at 12 o'clock: rotate the brush by -90deg around
the center, like the skia renderer does. Also use the gradient's
explicit center (with bounding-box fallback) instead of hardcoding the
bounding-box center.

basic/conic-gradients now matches the skia reference up to gradient
interpolation differences (9.6% of pixels off by more than 32/255,
down from 91%), and the conic row of
basic/radial-gradient-explicit-center falls in line (5.8%, down from
29%).
Gradient brushes for border rectangles were sized from the geometry
after adjust_rect_and_border_for_inner_drawing shrank it by the border
width, shifting the gradient center inward and shrinking its extent.
Capture the original element bounds first and use them for both the
fill and the stroke brush, like the skia renderer does.

basic/gradient-borders now matches the skia reference up to gradient
interpolation and anti-aliasing differences (1.3% of pixels off by
more than 32/255, down from 42%).
Extend::Repeat wraps the entire brush image, but a tiled fit's tile is
only the clip_rect portion of the source - the individual slice for
nine-slice images, or the source-clip region. Crop the tile out of the
image data first (the equivalent of the skia renderer's make_subset),
and drop the now-redundant clip offset from the tiled brush transform.

image/border-image-repeat now matches the skia reference exactly (was
51% of pixels off by more than 32/255).
Layouts routinely place items at fractional positions; with bilinear
sampling, the resulting fractional tile phase blends adjacent texels
across every tile seam and washes out the pattern. Round the fill's
translation to whole device pixels for tiled draws when the transform
is a pure translation, matching the skia renderer's integer tile
shader matrix.

With this, image/image-repeat matches the software renderer at least
as closely as skia does in every sub-image; the remaining divergences
in that case (round-tiling scale selection, out-of-bounds source-clip)
are places where skia and software already disagree with each other.
vello renders gradients whose stop offsets are out of range or not
strictly increasing as a solid fill of the first color. Slint allows
both: stops beyond 100% and duplicate positions (hard color steps).

Massage the stops before building the peniko gradient: sort them,
replace stops below 0 with the interpolated color at 0, divide by the
maximum offset when it exceeds 1 while growing the gradient geometry
(radius or line) by the same factor - clamping instead for conic
gradients, whose angular range can't extend - and separate duplicate
offsets by the smallest representable amount.

The two affected tiles of basic/radial-gradients (stops beyond 100%,
duplicate stop positions) previously rendered as solid red; the case
now matches the skia reference within the gradient-interpolation noise
floor (3% of pixels off by more than 32/255, down from 19%).
anyrender/vello/kurbo/peniko and a few rendering terms (texels,
unpremultiply, goldens, ...) used by the new i-slint-renderer-anyrender
crate and its screenshot tests.
Converting a Slint image for anyrender (RGB expansion, SVG
rasterization, tile cropping) ran on every frame, and handing out a
fresh peniko::Blob each time also defeats the resource caches that
anyrender backends key on Blob::id. SVGs were rasterized through usvg
on every single frame.

Cache the conversions following the pattern of the femtovg and skia
renderers: a per-item ItemCache holds the strong references (with
automatic property-tracked invalidation, released in
free_graphics_resources), and a shared map deduplicates conversions
across items, drained by refcount once per frame. Repeating tiles stay
cached as long as their source image lives; SVG entries are keyed by
rasterized size. This replaces the previous global-image-cache
BackendStorage round-trip, which the 5 MB global cache limit and
property-cached Image values made mostly ineffective.

The image goldens change because the recorded scenes now reference one
deduplicated image resource instead of repeated copies.
let phys_size = size * sf;

// anyrender's box-shadow primitive supports a single uniform corner
// radius; approximate per-corner radii with their average.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Is there an issue we can link to?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yes indeed. I forgot to link it. Done now.

}

fn apply_opacity(&mut self, _opacity: f32) {
// Opacity is applied through the alpha layer pushed in

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

If you expect this to never be called, replace it with an unreachable! with this message. Otherwise it's easy to silently do the wrong thing.

border: Option<sharedparley::RectangleBorder<Self::PlatformBrush>>,
) {
let shape =
kurbo::RoundedRect::from_rect(to_kurbo_rect(physical_rect), radius.get() as f64);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Maybe use Rect if radius is zero?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I was thinking about this "optimisation" as well and at the moment it has no effect (because of the way anyrender uses kurbo). I thought of doing that as a follow-up (first PR to anyrender), but I might as well pack it all into one then first. Fixups pushed with a new RectShape that implements shape.

Comment on lines +869 to +870
/// by `offset`). Relies on the linearity of the Gaussian —
/// blur(complement of rect) = 1 − blur(rect) — by filling the clipped

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Uses em-dash and unicode subtraction symbol instead of hyphen. Frankly this whole sentence should be rewritten, it's an unnecessary run-on sentence.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Rephrased. Is it better now?

@eira-fransham eira-fransham Jul 28, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I can't find the new comment, could you link me to it?

EDIT: Never mind, found it. The new comment is perfect, no issues here.

let interior_radius = (base_radius - spread).max(0.);
// Bounded to the border box (the outer clip limits the shadow to it
// anyway): destructive compose modes must not use an unbounded layer
// (see the UNCLIPPED comment).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

UNCLIPPED comment is unnecessarily vague. Please mention where this comment is, e.g. "the comment labelled UNCLIPPED in some_function_name".

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done

/// caller-supplied base color and a `Result`-returning closure, and a
/// fallible resize.
pub trait SlintWindowRenderer: anyrender::WindowRenderer {
fn slint_render<F>(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Missing doc comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Added

where
F: FnOnce(&mut Self::ScenePainter<'_>) -> Result<(), PlatformError>;

fn slint_set_size(&mut self, width: u32, height: u32) -> Result<(), PlatformError>;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Missing doc comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Added

Comment thread internal/renderers/anyrender/lib.rs Outdated
window_renderer: RefCell<W>,
image_cache: RefCell<ImageConversionCache>,
item_image_cache: i_slint_core::item_rendering::ItemCache<Option<SharedImageData>>,
rendering_metrics_collector: std::cell::OnceCell<

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This should be extracted to a separate type, it's a very complex type. Also, the std types should just be imported instead of qualified.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done, with an alias.

Self { scene: Scene::default(), base_color: None, width: 0, height: 0 }
}

/// Take ownership of the recorded scene, leaving an empty one in place.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
/// Take ownership of the recorded scene, leaving an empty one in place.
/// Take ownership of the recorded scene, leaving an empty one in its place.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Applied :)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Great addition here.

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.

2 participants