🐛 Fix shadows and blurs for high levels of zoom

This commit is contained in:
Alejandro Alonso
2025-11-13 12:43:44 +01:00
parent d26c08f8e2
commit 24745bed40
8 changed files with 788 additions and 10118 deletions

View File

@@ -1,6 +1,6 @@
use skia_safe::{self as skia, Paint, RRect};
use super::{RenderState, SurfaceId};
use super::{filters, RenderState, SurfaceId};
use crate::render::get_source_rect;
use crate::shapes::{Fill, Frame, ImageFill, Rect, Shape, Type};
@@ -100,34 +100,55 @@ pub fn render(
) {
let mut paint = fill.to_paint(&shape.selrect, antialias);
if let Some(image_filter) = shape.image_filter(1.) {
paint.set_image_filter(image_filter);
let bounds = image_filter.compute_fast_bounds(shape.selrect);
if filters::render_with_filter_surface(
render_state,
bounds,
surface_id,
|state, temp_surface| {
let mut filtered_paint = paint.clone();
filtered_paint.set_image_filter(image_filter.clone());
draw_fill_to_surface(state, shape, fill, antialias, temp_surface, &filtered_paint);
},
) {
return;
} else {
paint.set_image_filter(image_filter);
}
}
draw_fill_to_surface(render_state, shape, fill, antialias, surface_id, &paint);
}
fn draw_fill_to_surface(
render_state: &mut RenderState,
shape: &Shape,
fill: &Fill,
antialias: bool,
surface_id: SurfaceId,
paint: &Paint,
) {
match (fill, &shape.shape_type) {
(Fill::Image(image_fill), _) => {
draw_image_fill(
render_state,
shape,
image_fill,
&paint,
paint,
antialias,
surface_id,
);
}
(_, Type::Rect(_) | Type::Frame(_)) => {
render_state
.surfaces
.draw_rect_to(surface_id, shape, &paint);
render_state.surfaces.draw_rect_to(surface_id, shape, paint);
}
(_, Type::Circle) => {
render_state
.surfaces
.draw_circle_to(surface_id, shape, &paint);
.draw_circle_to(surface_id, shape, paint);
}
(_, Type::Path(_)) | (_, Type::Bool(_)) => {
render_state
.surfaces
.draw_path_to(surface_id, shape, &paint);
render_state.surfaces.draw_path_to(surface_id, shape, paint);
}
(_, Type::Group(_)) => {
// Groups can have fills but they propagate them to their children

View File

@@ -1,4 +1,6 @@
use skia_safe::ImageFilter;
use skia_safe::{self as skia, ImageFilter, Rect};
use super::{RenderState, SurfaceId};
/// Composes two image filters, returning a combined filter if both are present,
/// or the individual filter if only one is present, or None if neither is present.
@@ -21,3 +23,111 @@ pub fn compose_filters(
(None, None) => None,
}
}
/// Renders filtered content offscreen and composites it back into the target surface.
///
/// This helper is meant for shapes that rely on blur/filters that should be evaluated
/// in document space, regardless of the zoom level currently applied on the main canvas.
/// It draws the filtered content into `SurfaceId::Filter`, optionally downscales the
/// offscreen canvas when the requested bounds exceed the filter surface dimensions, and
/// then draws the resulting image into `target_surface`, scaling it back up if needed.
pub fn render_with_filter_surface<F>(
render_state: &mut RenderState,
bounds: Rect,
target_surface: SurfaceId,
draw_fn: F,
) -> bool
where
F: FnOnce(&mut RenderState, SurfaceId),
{
if let Some((image, scale)) = render_into_filter_surface(render_state, bounds, draw_fn) {
let canvas = render_state.surfaces.canvas(target_surface);
// If we scaled down, we need to scale the source rect and adjust the destination
if scale < 1.0 {
// The image was rendered at a smaller scale, so we need to scale it back up
let scaled_width = bounds.width() * scale;
let scaled_height = bounds.height() * scale;
let src_rect = skia::Rect::from_xywh(0.0, 0.0, scaled_width, scaled_height);
canvas.save();
canvas.scale((1.0 / scale, 1.0 / scale));
canvas.draw_image_rect_with_sampling_options(
image,
Some((&src_rect, skia::canvas::SrcRectConstraint::Strict)),
skia::Rect::from_xywh(
bounds.left * scale,
bounds.top * scale,
scaled_width,
scaled_height,
),
render_state.sampling_options,
&skia::Paint::default(),
);
canvas.restore();
} else {
// No scaling needed, draw normally
let src_rect = skia::Rect::from_xywh(0.0, 0.0, bounds.width(), bounds.height());
canvas.draw_image_rect_with_sampling_options(
image,
Some((&src_rect, skia::canvas::SrcRectConstraint::Strict)),
bounds,
render_state.sampling_options,
&skia::Paint::default(),
);
}
true
} else {
false
}
}
/// Creates/clears `SurfaceId::Filter`, prepares it for drawing the filtered content,
/// and executes the provided `draw_fn`.
///
/// If the requested bounds are larger than the filter surface, the canvas is scaled
/// down so that everything fits; the returned `scale` tells the caller how much the
/// content was reduced so it can be re-scaled on compositing. The `draw_fn` should
/// render the untransformed shape (i.e. in document coordinates) onto `SurfaceId::Filter`.
pub fn render_into_filter_surface<F>(
render_state: &mut RenderState,
bounds: Rect,
draw_fn: F,
) -> Option<(skia::Image, f32)>
where
F: FnOnce(&mut RenderState, SurfaceId),
{
if !bounds.is_finite() || bounds.width() <= 0.0 || bounds.height() <= 0.0 {
return None;
}
let filter_id = SurfaceId::Filter;
let (filter_width, filter_height) = render_state.surfaces.filter_size();
let bounds_width = bounds.width().ceil().max(1.0) as i32;
let bounds_height = bounds.height().ceil().max(1.0) as i32;
// Calculate scale factor if bounds exceed filter surface size
let scale = if bounds_width > filter_width || bounds_height > filter_height {
let scale_x = filter_width as f32 / bounds_width as f32;
let scale_y = filter_height as f32 / bounds_height as f32;
// Use the smaller scale to ensure everything fits
scale_x.min(scale_y).max(0.1) // Clamp to minimum 0.1 to avoid extreme scaling
} else {
1.0
};
{
let canvas = render_state.surfaces.canvas(filter_id);
canvas.clear(skia::Color::TRANSPARENT);
canvas.save();
// Apply scale first, then translate
canvas.scale((scale, scale));
canvas.translate((-bounds.left, -bounds.top));
}
draw_fn(render_state, filter_id);
render_state.surfaces.canvas(filter_id).restore();
Some((render_state.surfaces.snapshot(filter_id), scale))
}

View File

@@ -5,7 +5,7 @@ use crate::shapes::{
};
use skia_safe::{self as skia, ImageFilter, RRect};
use super::{RenderState, SurfaceId};
use super::{filters, RenderState, SurfaceId};
use crate::render::filters::compose_filters;
use crate::render::{get_dest_rect, get_source_rect};
@@ -378,6 +378,7 @@ fn draw_image_stroke_in_container(
stroke: &Stroke,
image_fill: &ImageFill,
antialias: bool,
surface_id: SurfaceId,
) {
let scale = render_state.get_scale();
let image = render_state.images.get(&image_fill.id());
@@ -386,7 +387,7 @@ fn draw_image_stroke_in_container(
}
let size = image.unwrap().dimensions();
let canvas = render_state.surfaces.canvas(SurfaceId::Strokes);
let canvas = render_state.surfaces.canvas(surface_id);
let container = &shape.selrect;
let path_transform = shape.to_path_transform();
let svg_attrs = shape.svg_attrs.as_ref();
@@ -523,10 +524,89 @@ pub fn render(
shadow: Option<&ImageFilter>,
antialias: bool,
) {
render_internal(
render_state,
shape,
stroke,
surface_id,
shadow,
antialias,
false,
);
}
/// Internal function to render a stroke with support for offscreen blur rendering.
///
/// # Parameters
/// - `render_state`: The rendering state containing surfaces and context.
/// - `shape`: The shape to render the stroke for.
/// - `stroke`: The stroke configuration (width, fill, style, etc.).
/// - `surface_id`: Optional target surface ID. Defaults to `SurfaceId::Strokes` if `None`.
/// - `shadow`: Optional shadow filter to apply to the stroke.
/// - `antialias`: Whether to use antialiasing for rendering.
/// - `bypass_filter`:
/// - If `false`, attempts to use offscreen filter surface for blur effects.
/// - If `true`, renders directly to the target surface (used for recursive calls to avoid infinite loops when rendering into the filter surface).
///
/// # Behavior
/// When `bypass_filter` is `false` and the shape has a blur filter:
/// 1. Calculates bounds including stroke width and cap margins.
/// 2. Attempts to render into an offscreen filter surface at unscaled coordinates.
/// 3. If successful, composites the result back to the target surface and returns early.
/// 4. If the offscreen render fails or `bypass_filter` is `true`, renders directly to the target
/// surface using the appropriate drawing function for the shape type.
///
/// The recursive call with `bypass_filter=true` ensures that when rendering into the filter
/// surface, we don't attempt to create another filter surface, avoiding infinite recursion.
#[allow(clippy::too_many_arguments)]
fn render_internal(
render_state: &mut RenderState,
shape: &Shape,
stroke: &Stroke,
surface_id: Option<SurfaceId>,
shadow: Option<&ImageFilter>,
antialias: bool,
bypass_filter: bool,
) {
if !bypass_filter {
if let Some(image_filter) = shape.image_filter(1.) {
// We have to calculate the bounds considering the stroke and the cap margins.
let mut content_bounds = shape.selrect;
let stroke_margin = stroke.bounds_width(shape.is_open());
if stroke_margin > 0.0 {
content_bounds.inset((-stroke_margin, -stroke_margin));
}
let cap_margin = stroke.cap_bounds_margin();
if cap_margin > 0.0 {
content_bounds.inset((-cap_margin, -cap_margin));
}
let bounds = image_filter.compute_fast_bounds(content_bounds);
let target = surface_id.unwrap_or(SurfaceId::Strokes);
if filters::render_with_filter_surface(
render_state,
bounds,
target,
|state, temp_surface| {
render_internal(
state,
shape,
stroke,
Some(temp_surface),
shadow,
antialias,
true,
);
},
) {
return;
}
}
}
let scale = render_state.get_scale();
let canvas = render_state
.surfaces
.canvas(surface_id.unwrap_or(surface_id.unwrap_or(SurfaceId::Strokes)));
let target_surface = surface_id.unwrap_or(SurfaceId::Strokes);
let canvas = render_state.surfaces.canvas(target_surface);
let selrect = shape.selrect;
let path_transform = shape.to_path_transform();
let svg_attrs = shape.svg_attrs.as_ref();
@@ -536,7 +616,14 @@ pub fn render(
&& matches!(stroke.fill, Fill::Image(_))
{
if let Fill::Image(image_fill) = &stroke.fill {
draw_image_stroke_in_container(render_state, shape, stroke, image_fill, antialias);
draw_image_stroke_in_container(
render_state,
shape,
stroke,
image_fill,
antialias,
target_surface,
);
}
} else {
match &shape.shape_type {

View File

@@ -18,20 +18,22 @@ const TILE_SIZE_MULTIPLIER: i32 = 2;
#[derive(Debug, PartialEq, Clone, Copy)]
pub enum SurfaceId {
Target = 0b00_0000_0001,
Cache = 0b00_0000_0010,
Current = 0b00_0000_0100,
Fills = 0b00_0000_1000,
Strokes = 0b00_0001_0000,
DropShadows = 0b00_0010_0000,
InnerShadows = 0b00_0100_0000,
TextDropShadows = 0b00_1000_0000,
UI = 0b01_0000_0000,
Filter = 0b00_0000_0010,
Cache = 0b00_0000_0100,
Current = 0b00_0000_1000,
Fills = 0b00_0001_0000,
Strokes = 0b00_0010_0000,
DropShadows = 0b00_0100_0000,
InnerShadows = 0b00_1000_0000,
TextDropShadows = 0b01_0000_0000,
UI = 0b10_0000_0000,
Debug = 0b10_0000_0001,
}
pub struct Surfaces {
// is the final destination surface, the one that it is represented in the canvas element.
target: skia::Surface,
filter: skia::Surface,
cache: skia::Surface,
// keeps the current render
current: skia::Surface,
@@ -70,6 +72,7 @@ impl Surfaces {
let margins = skia::ISize::new(extra_tile_dims.width / 4, extra_tile_dims.height / 4);
let target = gpu_state.create_target_surface(width, height);
let filter = gpu_state.create_surface_with_dimensions("filter".to_string(), width, height);
let cache = gpu_state.create_surface_with_dimensions("cache".to_string(), width, height);
let current = gpu_state.create_surface_with_isize("current".to_string(), extra_tile_dims);
let drop_shadows =
@@ -89,6 +92,7 @@ impl Surfaces {
let tiles = TileTextureCache::new();
Surfaces {
target,
filter,
cache,
current,
drop_shadows,
@@ -113,6 +117,10 @@ impl Surfaces {
surface.image_snapshot()
}
pub fn filter_size(&self) -> (i32, i32) {
(self.filter.width(), self.filter.height())
}
pub fn base64_snapshot(&mut self, id: SurfaceId) -> String {
let surface = self.get_mut(id);
let image = surface.image_snapshot();
@@ -157,6 +165,9 @@ impl Surfaces {
if ids & SurfaceId::Target as u32 != 0 {
f(self.get_mut(SurfaceId::Target));
}
if ids & SurfaceId::Filter as u32 != 0 {
f(self.get_mut(SurfaceId::Filter));
}
if ids & SurfaceId::Current as u32 != 0 {
f(self.get_mut(SurfaceId::Current));
}
@@ -215,6 +226,7 @@ impl Surfaces {
fn get_mut(&mut self, id: SurfaceId) -> &mut skia::Surface {
match id {
SurfaceId::Target => &mut self.target,
SurfaceId::Filter => &mut self.filter,
SurfaceId::Cache => &mut self.cache,
SurfaceId::Current => &mut self.current,
SurfaceId::DropShadows => &mut self.drop_shadows,
@@ -230,6 +242,7 @@ impl Surfaces {
fn reset_from_target(&mut self, target: skia::Surface) {
let dim = (target.width(), target.height());
self.target = target;
self.filter = self.target.new_surface_with_dimensions(dim).unwrap();
self.debug = self.target.new_surface_with_dimensions(dim).unwrap();
self.ui = self.target.new_surface_with_dimensions(dim).unwrap();
// The rest are tile size surfaces

View File

@@ -1,4 +1,4 @@
use super::{RenderState, Shape, SurfaceId};
use super::{filters, RenderState, Shape, SurfaceId};
use crate::{
math::Rect,
shapes::{
@@ -168,35 +168,71 @@ pub fn render(
shadow: Option<&Paint>,
blur: Option<&ImageFilter>,
) {
let render_canvas = if let Some(rs) = render_state {
rs.surfaces.canvas(surface_id.unwrap_or(SurfaceId::Fills))
} else if let Some(c) = canvas {
c
} else {
return;
};
if let Some(render_state) = render_state {
let target_surface = surface_id.unwrap_or(SurfaceId::Fills);
if let Some(blur_filter) = blur {
let bounds = blur_filter.compute_fast_bounds(shape.selrect);
if bounds.is_finite() && bounds.width() > 0.0 && bounds.height() > 0.0 {
let blur_filter_clone = blur_filter.clone();
if filters::render_with_filter_surface(
render_state,
bounds,
target_surface,
|state, temp_surface| {
let temp_canvas = state.surfaces.canvas(temp_surface);
render_text_on_canvas(
temp_canvas,
shape,
paragraph_builders,
shadow,
Some(&blur_filter_clone),
);
},
) {
return;
}
}
}
let canvas = render_state.surfaces.canvas(target_surface);
render_text_on_canvas(canvas, shape, paragraph_builders, shadow, blur);
return;
}
if let Some(canvas) = canvas {
render_text_on_canvas(canvas, shape, paragraph_builders, shadow, blur);
}
}
fn render_text_on_canvas(
canvas: &Canvas,
shape: &Shape,
paragraph_builders: &mut [Vec<ParagraphBuilder>],
shadow: Option<&Paint>,
blur: Option<&ImageFilter>,
) {
if let Some(blur_filter) = blur {
let mut blur_paint = Paint::default();
blur_paint.set_image_filter(blur_filter.clone());
let blur_layer = SaveLayerRec::default().paint(&blur_paint);
render_canvas.save_layer(&blur_layer);
canvas.save_layer(&blur_layer);
}
if let Some(shadow_paint) = shadow {
let layer_rec = SaveLayerRec::default().paint(shadow_paint);
render_canvas.save_layer(&layer_rec);
draw_text(render_canvas, shape, paragraph_builders);
render_canvas.restore();
canvas.save_layer(&layer_rec);
draw_text(canvas, shape, paragraph_builders);
canvas.restore();
} else {
draw_text(render_canvas, shape, paragraph_builders);
draw_text(canvas, shape, paragraph_builders);
}
if blur.is_some() {
render_canvas.restore();
canvas.restore();
}
render_canvas.restore();
canvas.restore();
}
fn draw_text(