Initial anyrender implementation - #12576
Conversation
4ec7a00 to
342b767
Compare
1afdbbf to
b7d06c3
Compare
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. |
There was a problem hiding this comment.
Is there an issue we can link to?
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
Maybe use Rect if radius is zero?
There was a problem hiding this comment.
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.
| /// by `offset`). Relies on the linearity of the Gaussian — | ||
| /// blur(complement of rect) = 1 − blur(rect) — by filling the clipped |
There was a problem hiding this comment.
Uses em-dash and unicode subtraction symbol instead of hyphen. Frankly this whole sentence should be rewritten, it's an unnecessary run-on sentence.
There was a problem hiding this comment.
Rephrased. Is it better now?
There was a problem hiding this comment.
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). |
There was a problem hiding this comment.
UNCLIPPED comment is unnecessarily vague. Please mention where this comment is, e.g. "the comment labelled UNCLIPPED in some_function_name".
| /// caller-supplied base color and a `Result`-returning closure, and a | ||
| /// fallible resize. | ||
| pub trait SlintWindowRenderer: anyrender::WindowRenderer { | ||
| fn slint_render<F>( |
| where | ||
| F: FnOnce(&mut Self::ScenePainter<'_>) -> Result<(), PlatformError>; | ||
|
|
||
| fn slint_set_size(&mut self, width: u32, height: u32) -> Result<(), PlatformError>; |
| 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< |
There was a problem hiding this comment.
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.
| Self { scene: Scene::default(), base_color: None, width: 0, height: 0 } | ||
| } | ||
|
|
||
| /// Take ownership of the recorded scene, leaving an empty one in place. |
There was a problem hiding this comment.
| /// 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. |
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 :)