gpui: Fix drawing rotated SVGs (#33288)

Sunli and Jason Lee created

Fixes: https://github.com/longbridge/gpui-component/issues/994

1. When SVG is rotated, incorrect graphics are drawn.

For example: the original aspect ratio of the SVG is 1:1, if the bounds
used to render the SVG are 400x200 (aspect ratio 2:1),
[here](https://github.com/zed-industries/zed/blob/21f985a018f7cca9c0fb7f5b7a87555486ab9db5/crates/gpui/src/svg_renderer.rs#L91)
the width is used as the scaling factor, causing the rendered SVG to
only have half the height. This PR ensures the complete SVG image is
always rendered.

2. The clipping region has no transformation applied, I added a function
called `distance_from_clip_rect_transformed` in the shader.

3. Fixed `monochrome_sprite_fragment` in `shader.metal` not applying
clipping region.

### Before:


https://github.com/user-attachments/assets/8f93ac36-281e-4837-96cd-c308bfbf92d1

### After:


https://github.com/user-attachments/assets/f52b67a6-4cb9-4d6c-b759-bbb91b59c1cf

Release Notes:

- N/A

---------

Co-authored-by: Jason Lee <huacnlee@gmail.com>

Change summary

crates/gpui/examples/animation.rs             | 85 ++++++++++++++------
crates/gpui/src/platform/blade/shaders.wgsl   |  8 +
crates/gpui/src/platform/mac/shaders.metal    | 30 ++++++
crates/gpui/src/platform/windows/shaders.hlsl |  8 +
crates/gpui/src/svg_renderer.rs               | 30 ++++--
crates/gpui/src/window.rs                     | 19 +++-
6 files changed, 130 insertions(+), 50 deletions(-)

Detailed changes

crates/gpui/examples/animation.rs 🔗

@@ -3,8 +3,8 @@ use std::time::Duration;
 use anyhow::Result;
 use gpui::{
     Animation, AnimationExt as _, App, Application, AssetSource, Bounds, Context, SharedString,
-    Transformation, Window, WindowBounds, WindowOptions, black, bounce, div, ease_in_out,
-    percentage, prelude::*, px, rgb, size, svg,
+    Transformation, Window, WindowBounds, WindowOptions, bounce, div, ease_in_out, percentage,
+    prelude::*, px, size, svg,
 };
 
 struct Assets {}
@@ -37,37 +37,66 @@ struct AnimationExample {}
 
 impl Render for AnimationExample {
     fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
-        div().flex().flex_col().size_full().justify_around().child(
-            div().flex().flex_row().w_full().justify_around().child(
+        div()
+            .flex()
+            .flex_col()
+            .size_full()
+            .bg(gpui::white())
+            .text_color(gpui::black())
+            .justify_around()
+            .child(
                 div()
                     .flex()
-                    .bg(rgb(0x2e7d32))
-                    .size(px(300.0))
-                    .justify_center()
-                    .items_center()
-                    .shadow_lg()
-                    .text_xl()
-                    .text_color(black())
-                    .child("hello")
+                    .flex_col()
+                    .size_full()
+                    .justify_around()
                     .child(
-                        svg()
-                            .size_8()
-                            .path(ARROW_CIRCLE_SVG)
-                            .text_color(black())
-                            .with_animation(
-                                "image_circle",
-                                Animation::new(Duration::from_secs(2))
-                                    .repeat()
-                                    .with_easing(bounce(ease_in_out)),
-                                |svg, delta| {
-                                    svg.with_transformation(Transformation::rotate(percentage(
-                                        delta,
-                                    )))
-                                },
+                        div()
+                            .id("content")
+                            .flex()
+                            .flex_col()
+                            .h(px(150.))
+                            .overflow_y_scroll()
+                            .w_full()
+                            .flex_1()
+                            .justify_center()
+                            .items_center()
+                            .text_xl()
+                            .gap_4()
+                            .child("Hello Animation")
+                            .child(
+                                svg()
+                                    .size_20()
+                                    .overflow_hidden()
+                                    .path(ARROW_CIRCLE_SVG)
+                                    .text_color(gpui::black())
+                                    .with_animation(
+                                        "image_circle",
+                                        Animation::new(Duration::from_secs(2))
+                                            .repeat()
+                                            .with_easing(bounce(ease_in_out)),
+                                        |svg, delta| {
+                                            svg.with_transformation(Transformation::rotate(
+                                                percentage(delta),
+                                            ))
+                                        },
+                                    ),
                             ),
+                    )
+                    .child(
+                        div()
+                            .flex()
+                            .h(px(64.))
+                            .w_full()
+                            .p_2()
+                            .justify_center()
+                            .items_center()
+                            .border_t_1()
+                            .border_color(gpui::black().opacity(0.1))
+                            .bg(gpui::black().opacity(0.05))
+                            .child("Other Panel"),
                     ),
-            ),
-        )
+            )
     }
 }
 

crates/gpui/src/platform/blade/shaders.wgsl 🔗

@@ -172,6 +172,12 @@ fn distance_from_clip_rect(unit_vertex: vec2<f32>, bounds: Bounds, clip_bounds:
     return distance_from_clip_rect_impl(position, clip_bounds);
 }
 
+fn distance_from_clip_rect_transformed(unit_vertex: vec2<f32>, bounds: Bounds, clip_bounds: Bounds, transform: TransformationMatrix) -> vec4<f32> {
+    let position = unit_vertex * vec2<f32>(bounds.size) + bounds.origin;
+    let transformed = transpose(transform.rotation_scale) * position + transform.translation;
+    return distance_from_clip_rect_impl(transformed, clip_bounds);
+}
+
 // https://gamedev.stackexchange.com/questions/92015/optimized-linear-to-srgb-glsl
 fn srgb_to_linear(srgb: vec3<f32>) -> vec3<f32> {
     let cutoff = srgb < vec3<f32>(0.04045);
@@ -1150,7 +1156,7 @@ fn vs_mono_sprite(@builtin(vertex_index) vertex_id: u32, @builtin(instance_index
 
     out.tile_position = to_tile_position(unit_vertex, sprite.tile);
     out.color = hsla_to_rgba(sprite.color);
-    out.clip_distances = distance_from_clip_rect(unit_vertex, sprite.bounds, sprite.content_mask);
+    out.clip_distances = distance_from_clip_rect_transformed(unit_vertex, sprite.bounds, sprite.content_mask, sprite.transformation);
     return out;
 }
 

crates/gpui/src/platform/mac/shaders.metal 🔗

@@ -18,6 +18,8 @@ float2 to_tile_position(float2 unit_vertex, AtlasTile tile,
                         constant Size_DevicePixels *atlas_size);
 float4 distance_from_clip_rect(float2 unit_vertex, Bounds_ScaledPixels bounds,
                                Bounds_ScaledPixels clip_bounds);
+float4 distance_from_clip_rect_transformed(float2 unit_vertex, Bounds_ScaledPixels bounds,
+                               Bounds_ScaledPixels clip_bounds, TransformationMatrix transformation);
 float corner_dash_velocity(float dv1, float dv2);
 float dash_alpha(float t, float period, float length, float dash_velocity,
                  float antialias_threshold);
@@ -599,13 +601,14 @@ struct MonochromeSpriteVertexOutput {
   float4 position [[position]];
   float2 tile_position;
   float4 color [[flat]];
-  float clip_distance [[clip_distance]][4];
+  float4 clip_distance;
 };
 
 struct MonochromeSpriteFragmentInput {
   float4 position [[position]];
   float2 tile_position;
   float4 color [[flat]];
+  float4 clip_distance;
 };
 
 vertex MonochromeSpriteVertexOutput monochrome_sprite_vertex(
@@ -620,8 +623,8 @@ vertex MonochromeSpriteVertexOutput monochrome_sprite_vertex(
   MonochromeSprite sprite = sprites[sprite_id];
   float4 device_position =
       to_device_position_transformed(unit_vertex, sprite.bounds, sprite.transformation, viewport_size);
-  float4 clip_distance = distance_from_clip_rect(unit_vertex, sprite.bounds,
-                                                 sprite.content_mask.bounds);
+  float4 clip_distance = distance_from_clip_rect_transformed(unit_vertex, sprite.bounds,
+                                                 sprite.content_mask.bounds, sprite.transformation);
   float2 tile_position = to_tile_position(unit_vertex, sprite.tile, atlas_size);
   float4 color = hsla_to_rgba(sprite.color);
   return MonochromeSpriteVertexOutput{
@@ -635,6 +638,10 @@ fragment float4 monochrome_sprite_fragment(
     MonochromeSpriteFragmentInput input [[stage_in]],
     constant MonochromeSprite *sprites [[buffer(SpriteInputIndex_Sprites)]],
     texture2d<float> atlas_texture [[texture(SpriteInputIndex_AtlasTexture)]]) {
+  if (any(input.clip_distance < float4(0.0))) {
+    return float4(0.0);
+  }
+
   constexpr sampler atlas_texture_sampler(mag_filter::linear,
                                           min_filter::linear);
   float4 sample =
@@ -1096,6 +1103,23 @@ float4 distance_from_clip_rect(float2 unit_vertex, Bounds_ScaledPixels bounds,
                 clip_bounds.origin.y + clip_bounds.size.height - position.y);
 }
 
+float4 distance_from_clip_rect_transformed(float2 unit_vertex, Bounds_ScaledPixels bounds,
+                               Bounds_ScaledPixels clip_bounds, TransformationMatrix transformation) {
+  float2 position =
+      unit_vertex * float2(bounds.size.width, bounds.size.height) +
+      float2(bounds.origin.x, bounds.origin.y);
+  float2 transformed_position = float2(0, 0);
+  transformed_position[0] = position[0] * transformation.rotation_scale[0][0] + position[1] * transformation.rotation_scale[0][1];
+  transformed_position[1] = position[0] * transformation.rotation_scale[1][0] + position[1] * transformation.rotation_scale[1][1];
+  transformed_position[0] += transformation.translation[0];
+  transformed_position[1] += transformation.translation[1];
+
+  return float4(transformed_position.x - clip_bounds.origin.x,
+                clip_bounds.origin.x + clip_bounds.size.width - transformed_position.x,
+                transformed_position.y - clip_bounds.origin.y,
+                clip_bounds.origin.y + clip_bounds.size.height - transformed_position.y);
+}
+
 float4 over(float4 below, float4 above) {
   float4 result;
   float alpha = above.a + below.a * (1.0 - above.a);

crates/gpui/src/platform/windows/shaders.hlsl 🔗

@@ -107,6 +107,12 @@ float4 distance_from_clip_rect(float2 unit_vertex, Bounds bounds, Bounds clip_bo
     return distance_from_clip_rect_impl(position, clip_bounds);
 }
 
+float4 distance_from_clip_rect_transformed(float2 unit_vertex, Bounds bounds, Bounds clip_bounds, TransformationMatrix transformation) {
+    float2 position = unit_vertex * bounds.size + bounds.origin;
+    float2 transformed = mul(position, transformation.rotation_scale) + transformation.translation;
+    return distance_from_clip_rect_impl(transformed, clip_bounds);
+}
+
 // Convert linear RGB to sRGB
 float3 linear_to_srgb(float3 color) {
     return pow(color, float3(2.2, 2.2, 2.2));
@@ -1088,7 +1094,7 @@ MonochromeSpriteVertexOutput monochrome_sprite_vertex(uint vertex_id: SV_VertexI
     MonochromeSprite sprite = mono_sprites[sprite_id];
     float4 device_position =
         to_device_position_transformed(unit_vertex, sprite.bounds, sprite.transformation);
-    float4 clip_distance = distance_from_clip_rect(unit_vertex, sprite.bounds, sprite.content_mask);
+    float4 clip_distance = distance_from_clip_rect_transformed(unit_vertex, sprite.bounds, sprite.content_mask, sprite.transformation);
     float2 tile_position = to_tile_position(unit_vertex, sprite.tile);
     float4 color = hsla_to_rgba(sprite.color);
 

crates/gpui/src/svg_renderer.rs 🔗

@@ -54,7 +54,10 @@ impl SvgRenderer {
         }
     }
 
-    pub(crate) fn render(&self, params: &RenderSvgParams) -> Result<Option<Vec<u8>>> {
+    pub(crate) fn render(
+        &self,
+        params: &RenderSvgParams,
+    ) -> Result<Option<(Size<DevicePixels>, Vec<u8>)>> {
         anyhow::ensure!(!params.size.is_zero(), "can't render at a zero size");
 
         // Load the tree.
@@ -65,30 +68,33 @@ impl SvgRenderer {
         let pixmap = self.render_pixmap(&bytes, SvgSize::Size(params.size))?;
 
         // Convert the pixmap's pixels into an alpha mask.
+        let size = Size::new(
+            DevicePixels(pixmap.width() as i32),
+            DevicePixels(pixmap.height() as i32),
+        );
         let alpha_mask = pixmap
             .pixels()
             .iter()
             .map(|p| p.alpha())
             .collect::<Vec<_>>();
-        Ok(Some(alpha_mask))
+        Ok(Some((size, alpha_mask)))
     }
 
     pub fn render_pixmap(&self, bytes: &[u8], size: SvgSize) -> Result<Pixmap, usvg::Error> {
         let tree = usvg::Tree::from_data(bytes, &self.usvg_options)?;
-
-        let size = match size {
-            SvgSize::Size(size) => size,
-            SvgSize::ScaleFactor(scale) => crate::size(
-                DevicePixels((tree.size().width() * scale) as i32),
-                DevicePixels((tree.size().height() * scale) as i32),
-            ),
+        let svg_size = tree.size();
+        let scale = match size {
+            SvgSize::Size(size) => size.width.0 as f32 / svg_size.width(),
+            SvgSize::ScaleFactor(scale) => scale,
         };
 
         // Render the SVG to a pixmap with the specified width and height.
-        let mut pixmap = resvg::tiny_skia::Pixmap::new(size.width.into(), size.height.into())
-            .ok_or(usvg::Error::InvalidSize)?;
+        let mut pixmap = resvg::tiny_skia::Pixmap::new(
+            (svg_size.width() * scale) as u32,
+            (svg_size.height() * scale) as u32,
+        )
+        .ok_or(usvg::Error::InvalidSize)?;
 
-        let scale = size.width.0 as f32 / tree.size().width();
         let transform = resvg::tiny_skia::Transform::from_scale(scale, scale);
 
         resvg::render(&tree, transform, &mut pixmap.as_mut());

crates/gpui/src/window.rs 🔗

@@ -3082,22 +3082,31 @@ impl Window {
         let Some(tile) =
             self.sprite_atlas
                 .get_or_insert_with(&params.clone().into(), &mut || {
-                    let Some(bytes) = cx.svg_renderer.render(&params)? else {
+                    let Some((size, bytes)) = cx.svg_renderer.render(&params)? else {
                         return Ok(None);
                     };
-                    Ok(Some((params.size, Cow::Owned(bytes))))
+                    Ok(Some((size, Cow::Owned(bytes))))
                 })?
         else {
             return Ok(());
         };
         let content_mask = self.content_mask().scale(scale_factor);
+        let svg_bounds = Bounds {
+            origin: bounds.center()
+                - Point::new(
+                    ScaledPixels(tile.bounds.size.width.0 as f32 / SMOOTH_SVG_SCALE_FACTOR / 2.),
+                    ScaledPixels(tile.bounds.size.height.0 as f32 / SMOOTH_SVG_SCALE_FACTOR / 2.),
+                ),
+            size: tile
+                .bounds
+                .size
+                .map(|value| ScaledPixels(value.0 as f32 / SMOOTH_SVG_SCALE_FACTOR)),
+        };
 
         self.next_frame.scene.insert_primitive(MonochromeSprite {
             order: 0,
             pad: 0,
-            bounds: bounds
-                .map_origin(|origin| origin.floor())
-                .map_size(|size| size.ceil()),
+            bounds: svg_bounds,
             content_mask,
             color: color.opacity(element_opacity),
             tile,