Skip to content

Fix canvas ghosting in the wgpu renderer#3364

Closed
zao111222333 wants to merge 1 commit into
iced-rs:masterfrom
zao111222333:master
Closed

Fix canvas ghosting in the wgpu renderer#3364
zao111222333 wants to merge 1 commit into
iced-rs:masterfrom
zao111222333:master

Conversation

@zao111222333

@zao111222333 zao111222333 commented Jun 15, 2026

Copy link
Copy Markdown

This PR fixes stale iced::widget::canvas pixels that can remain visible after a canvas is resized, clipped, hidden, or covered by another layer.

The issue was observed with the wgpu renderer on Red Hat 8 and Red Hat 9. The same application did not reproduce it on the macOS / CentOS 7 systems used for comparison. Layout, hit testing, scrollbars, and widget sizing were correct; only the rendered pixels were stale.

Reproduction

Repro Demo, RedHat 8
use iced::mouse;
use iced::time::{self, milliseconds};
use iced::widget::canvas::{self, Canvas, Geometry, Path, Stroke, stroke};
use iced::widget::{button, column, container, row, stack, text};
use iced::{
    Alignment, Color, Element, Fill, Length, Point, Rectangle, Renderer, Size, Subscription, Task,
    Theme,
};

use std::time::Instant;

pub fn main() -> iced::Result {
    iced::application(Ghosting::new, Ghosting::update, Ghosting::view)
        .subscription(Ghosting::subscription)
        .title(Ghosting::title)
        .theme(Ghosting::theme)
        .run()
}

struct Ghosting {
    started: Instant,
    left_width: f32,
    right_width: f32,
    show_dialog: bool,
}

#[derive(Debug, Clone, Copy)]
enum Message {
    Tick(Instant),
    ToggleDialog,
}

impl Ghosting {
    fn new() -> (Self, Task<Message>) {
        (
            Self {
                started: Instant::now(),
                left_width: 220.0,
                right_width: 220.0,
                show_dialog: false,
            },
            Task::none(),
        )
    }

    fn update(&mut self, message: Message) {
        match message {
            Message::Tick(now) => {
                let elapsed = now.duration_since(self.started);
                let seconds = elapsed.as_secs_f32();
                let phase = (seconds * std::f32::consts::TAU / 3.0).sin() * 0.5 + 0.5;

                self.left_width = 80.0 + 340.0 * phase;
                self.right_width = 80.0 + 340.0 * (1.0 - phase);
            }
            Message::ToggleDialog => {
                self.show_dialog = !self.show_dialog;
            }
        }
    }

    fn subscription(&self) -> Subscription<Message> {
        time::every(milliseconds(16)).map(Message::Tick)
    }

    fn title(&self) -> String {
        format!(
            "Canvas Ghosting (dialog {})",
            if self.show_dialog { "on" } else { "off" }
        )
    }

    fn theme(&self) -> Theme {
        Theme::Light
    }

    fn view(&self) -> Element<'_, Message> {
        let content = column![
            row![
                text(format!("Left {:.0}px", self.left_width)).width(120),
                text(format!("Right {:.0}px", self.right_width)).width(120),
                button("Toggle dialog").on_press(Message::ToggleDialog),
            ]
            .spacing(12)
            .align_y(Alignment::Center)
            .padding(6),
            row![
                side_panel("Left", self.left_width, Color::from_rgb(0.06, 0.47, 0.29)),
                Canvas::new(CircuitCanvas).width(Fill).height(Fill),
                side_panel("Right", self.right_width, Color::from_rgb(0.53, 0.11, 0.19)),
            ]
            .height(Fill),
        ]
        .width(Fill)
        .height(Fill);

        if self.show_dialog {
            let dialog = container(
                column![
                    text("Canvas Dialog").size(18),
                    Canvas::new(PlotCanvas)
                        .width(Fill)
                        .height(Length::Fixed(240.0)),
                    text("Text below the canvas must stay visible"),
                ]
                .spacing(8),
            )
            .width(Length::Fixed(640.0))
            .padding(8)
            .style(container::rounded_box);

            stack![
                content,
                container(dialog)
                    .width(Fill)
                    .height(Fill)
                    .center_x(Fill)
                    .center_y(Fill),
            ]
            .width(Fill)
            .height(Fill)
            .into()
        } else {
            content.into()
        }
    }
}

fn side_panel<'a>(title: &'static str, width: f32, color: Color) -> Element<'a, Message> {
    container(
        column![
            text(title).size(28),
            text("ordinary UI"),
            text("no canvas pixels should appear here"),
        ]
        .spacing(8)
        .align_x(Alignment::Center),
    )
    .width(Length::Fixed(width))
    .height(Fill)
    .center_x(Length::Fixed(width))
    .center_y(Fill)
    .style(move |_| container::Style {
        background: Some(color.into()),
        text_color: Some(Color::WHITE),
        ..container::Style::default()
    })
    .into()
}

struct CircuitCanvas;

impl<Message> canvas::Program<Message> for CircuitCanvas {
    type State = ();

    fn draw(
        &self,
        _state: &Self::State,
        renderer: &Renderer,
        _theme: &Theme,
        bounds: Rectangle,
        _cursor: mouse::Cursor,
    ) -> Vec<Geometry> {
        let mut frame = canvas::Frame::new(renderer, bounds.size());
        let size = frame.size();
        let background = Path::rectangle(Point::ORIGIN, size);

        frame.fill(&background, Color::from_rgb(0.02, 0.03, 0.06));
        draw_grid(&mut frame, size);
        draw_traces(
            &mut frame,
            size,
            Color::from_rgb(0.15, 0.86, 0.95),
            Color::from_rgb(0.95, 0.20, 0.88),
        );
        draw_blocks(&mut frame, Color::from_rgb(0.96, 0.74, 0.10));

        vec![frame.into_geometry()]
    }
}

struct PlotCanvas;

impl<Message> canvas::Program<Message> for PlotCanvas {
    type State = ();

    fn draw(
        &self,
        _state: &Self::State,
        renderer: &Renderer,
        _theme: &Theme,
        bounds: Rectangle,
        _cursor: mouse::Cursor,
    ) -> Vec<Geometry> {
        let mut frame = canvas::Frame::new(renderer, bounds.size());
        let size = frame.size();
        frame.fill_rectangle(Point::ORIGIN, size, Color::from_rgb(0.96, 0.97, 0.99));
        draw_plot_grid(&mut frame, size);
        draw_plot_lines(&mut frame, size);
        vec![frame.into_geometry()]
    }
}

fn draw_grid(frame: &mut canvas::Frame, size: Size) {
    let stroke = Stroke {
        width: 1.0,
        style: stroke::Style::Solid(Color::from_rgba(0.65, 0.75, 0.85, 0.35)),
        ..Stroke::default()
    };

    for x in (0..=size.width as usize).step_by(40) {
        frame.stroke(
            &Path::line(Point::new(x as f32, 0.0), Point::new(x as f32, size.height)),
            stroke,
        );
    }

    for y in (0..=size.height as usize).step_by(40) {
        frame.stroke(
            &Path::line(Point::new(0.0, y as f32), Point::new(size.width, y as f32)),
            stroke,
        );
    }
}

fn draw_traces(frame: &mut canvas::Frame, size: Size, a: Color, b: Color) {
    for offset in (-900..=900).step_by(110) {
        for (shift, color) in [(0.0, a), (size.height * 0.9, b)] {
            frame.stroke(
                &Path::line(
                    Point::new(offset as f32 + shift, 0.0),
                    Point::new(offset as f32 + size.height * 0.8 - shift, size.height),
                ),
                Stroke {
                    width: 3.0,
                    style: stroke::Style::Solid(color),
                    ..Stroke::default()
                },
            );
        }
    }
}

fn draw_blocks(frame: &mut canvas::Frame, color: Color) {
    for index in 0..8 {
        let x = 80.0 + index as f32 * 96.0;
        let y = if index % 2 == 0 { 150.0 } else { 380.0 };
        let block = Path::rectangle(Point::new(x, y), Size::new(54.0, 34.0));

        frame.fill(&block, Color::from_rgb(0.14, 0.17, 0.20));
        frame.stroke(
            &block,
            Stroke {
                width: 2.0,
                style: stroke::Style::Solid(color),
                ..Stroke::default()
            },
        );
    }
}

fn draw_plot_grid(frame: &mut canvas::Frame, size: Size) {
    let stroke = Stroke {
        width: 1.0,
        style: stroke::Style::Solid(Color::from_rgba(0.12, 0.16, 0.22, 0.25)),
        ..Stroke::default()
    };

    for x in (0..=size.width as usize).step_by(50) {
        frame.stroke(
            &Path::line(Point::new(x as f32, 0.0), Point::new(x as f32, size.height)),
            stroke,
        );
    }

    for y in (0..=size.height as usize).step_by(50) {
        frame.stroke(
            &Path::line(Point::new(0.0, y as f32), Point::new(size.width, y as f32)),
            stroke,
        );
    }
}

fn draw_plot_lines(frame: &mut canvas::Frame, size: Size) {
    for (offset, color) in [
        (0.0, Color::from_rgb(0.10, 0.44, 0.95)),
        (42.0, Color::from_rgb(0.88, 0.27, 0.20)),
    ] {
        let path = Path::new(|builder| {
            builder.move_to(Point::new(0.0, size.height * 0.70 + offset * 0.1));
            for step in 0..=18 {
                let t = step as f32 / 18.0;
                let x = t * size.width;
                let y =
                    size.height * (0.52 - 0.22 * (t * std::f32::consts::TAU * 1.6).sin()) + offset;
                builder.line_to(Point::new(x, y));
            }
        });

        frame.stroke(
            &path,
            Stroke {
                width: 3.0,
                style: stroke::Style::Solid(color),
                ..Stroke::default()
            },
        );
    }
}
Before:
Screen.Recording.2026-06-15.at.18.18.14.mp4

Now:

Screen.Recording.2026-06-15.at.19.02.11.mp4

Root Cause

The problem was in the iced_wgpu triangle rendering path, not in application layout or individual canvas widgets.

The MSAA triangle pipeline reuses an intermediate resolve texture. When a previous frame rendered a larger canvas, pixels could remain in untouched parts of that reused texture. A later frame with a smaller canvas or different clip bounds could then composite those stale pixels into the final frame.

Cached triangle uploads also need to account for the active clip bounds. The same mesh content can be valid under one clip and invalid under another.

Fix

  • Clear the MSAA triangle resolve texture to transparent before each triangle render pass.
  • Carry active clip bounds through triangle uniforms and discard out-of-bounds fragments in the solid and gradient triangle shaders.
  • Include active bounds in cached triangle upload state so cached meshes are re-prepared when clip bounds change.
  • Record the current mesh cache version when creating a cached upload, avoiding an unnecessary re-prepare on the next frame.
  • Prefer an opaque window surface alpha mode when the platform supports it, avoiding unnecessary compositor blending for normal opaque application windows.

Together, these changes make clipping and target reuse explicit in the renderer, so applications do not need widget-specific background clearing workarounds to hide stale canvas pixels.

@hecrj

hecrj commented Jun 16, 2026

Copy link
Copy Markdown
Member

The MSAA triangle pipeline reuses an intermediate resolve texture. When a previous frame rendered a larger canvas, pixels could remain in untouched parts of that reused texture. A later frame with a smaller canvas or different clip bounds could then composite those stale pixels into the final frame.

I don't see how this is possible. The msaa render pass uses wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT) which should clear the entire MSAA texture before resolving.

This looks like a graphics driver issue to me.

@zao111222333

Copy link
Copy Markdown
Author

This looks like a graphics driver issue to me.

You're right, I found the graphics adapter of Red Hat 8.10 is llvmpipe (LLVM 17.0.2, 256 bits), and it works well with tiny-skia backend, so I made another PR #3365 :)

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