rustrust-iced

How Can I Efficiently Render an Emulator Framebuffer in an Iced Widget at 60 FPS?


I’m working on an NES emulator in Rust using Iced for the UI. In my previous implementation, I used SDL2 and streamed the framebuffer into an SDL2 texture, which was then rendered at 60 FPS.

Now I want to migrate to Iced’s widgets for rendering. The emulator has a game loop that processes cycles and produces an RGBA framebuffer of size 256×240. I need to:

  1. Render this 256×240 RGBA image at 60 FPS inside an Iced application.
  2. Scale it based on the window size (like SDL2 did).

My initial thought was to use iced::widget::canvas::Canvas to draw the framebuffer pixel by pixel, but this doesn't seem like an efficient approach. Is there a better way to update a texture or image in Iced at 60 FPS without manually drawing every pixel in the Canvas draw method?


Solution

  • I'm not familiar with using iced myself, but as a general rule, this is the ranking of the efficiency of means of displaying an arbitrary animated image:

    1. Placing the image data in GPU memory (a “texture”) to be rendered/composited onto the window/screen by the GPU.
    2. Placing the image data in CPU memory and leaving it up to the UI framework to copy those pixels to the GPU.
    3. Using a “set pixel” or “draw rectangle” function for every pixel (your proposal).

    With a small image on today’s desktop computers — 256 × 240 counts as small — you can afford method 3, but you shouldn’t if you don't have to. iced does not appear to have a way to perform method 1, but it does have method 2: use an Image widget, and give it a Handle::Rgba containing your image pixels.


    Here is a program giving a proof of the concept; it is just edited from one of iced’s own examples, and I am not generally familiar with iced so this may contain unwise choices:

    [dependencies]
    iced = { version = "0.13.1", features = ["tokio", "image-without-codecs"] }
    
    use std::time::Duration;
    
    use iced::widget::center;
    use iced::widget::image::FilterMethod;
    use iced::Element;
    
    pub fn main() -> iced::Result {
        iced::application("Pixels", Example::update, Example::view)
            // This subscription is just to make the animation work
            .subscription(|_| iced::time::every(Duration::from_millis(16)).map(|_| Message::Step))
            .run()
    }
    
    #[derive(Default)]
    struct Example {
        frame: u64,
    }
    
    #[derive(Debug, Clone, Copy)]
    enum Message {
        Step,
    }
    
    impl Example {
        fn update(&mut self, message: Message) {
            match message {
                Message::Step => self.frame += 1,
            }
        }
    
        fn view(&self) -> Element<Message> {
            // Replace this simple demo effect with your emulator’s frame buffer
            let mut image = vec![0u8; 256 * 240 * 4];
            for x in 0..256 {
                for y in 0..240 {
                    let pixel_index = (x + y * 256) * 4;
                    let val = ((x ^ y) * 1) as u8;
                    image[pixel_index + 0] = val.wrapping_sub((self.frame * 100 / 80) as u8);
                    image[pixel_index + 1] = val.wrapping_sub((self.frame * 100 / 60) as u8);
                    image[pixel_index + 2] = val.wrapping_sub((self.frame * 100 / 40) as u8);
                    image[pixel_index + 3] = 255;
                }
            }
    
            let content = iced::widget::image(iced::widget::image::Handle::from_rgba(256, 240, image))
                .width(512)
                .height(480)
                .filter_method(FilterMethod::Nearest);
    
            center(content).into()
        }
    }
    

    The window should look like this, but animated:

    Screenshot of iced window