Skip to content
1 change: 0 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 8 additions & 2 deletions crates/ironrdp-web/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,19 @@ iron-remote-desktop.path = "../iron-remote-desktop"
# WASM
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["HtmlCanvasElement", "Navigator", "Performance", "Window"] }
web-sys = { version = "0.3", features = [
"CanvasRenderingContext2d",
"HtmlCanvasElement",
"ImageData",
"Navigator",
"Performance",
"Window",
] }
js-sys = "0.3"
gloo-net = { version = "0.7", default-features = false, features = ["websocket", "http", "io-util"] }
gloo-timers = { version = "0.4", default-features = false, features = ["futures"] }

# Rendering
softbuffer = { version = "0.4", default-features = false }
png = "0.18"
resize = { version = "0.8", features = ["std"], default-features = false }
rgb = "0.8"
Expand Down
151 changes: 81 additions & 70 deletions crates/ironrdp-web/src/canvas.rs
Original file line number Diff line number Diff line change
@@ -1,93 +1,104 @@
use core::num::NonZeroU32;

use anyhow::Context as _;
use ironrdp::pdu::geometry::{InclusiveRectangle, Rectangle as _};
use softbuffer::{NoDisplayHandle, NoWindowHandle};
use web_sys::HtmlCanvasElement;

#[cfg(target_arch = "wasm32")]
use anyhow::anyhow;
use ironrdp::pdu::geometry::InclusiveRectangle;
#[cfg(target_arch = "wasm32")]
use ironrdp::pdu::geometry::Rectangle as _;
#[cfg(target_arch = "wasm32")]
use wasm_bindgen::{Clamped, JsCast as _};
#[cfg(target_arch = "wasm32")]
use web_sys::ImageData;
use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement};

/// Web render surface. Owns the canvas's 2D context and a reusable RGBA scratch buffer: each dirty
/// region's pixels are copied once into the scratch (alpha forced opaque), then blitted with
/// `put_image_data` at the region's origin.
///
Comment thread
irvingoujAtDevolution marked this conversation as resolved.
Outdated
/// This replaced a softbuffer-backed path that converted RGBA -> u32 `0RGB` (our pass) and then let
/// softbuffer repack u32 -> RGBA per frame into a freshly allocated buffer — two pixel passes over
/// the whole surface plus a per-frame allocation. The replay benchmark (`src/bench.rs`, feature
/// `bench`) measures the direct path's present an order of magnitude faster at 4K with byte-identical
/// canvas output (FNV-1a over the rendered canvas pixels). Mirrors the same fix in IronVNC.
pub(crate) struct Canvas {
width: NonZeroU32,
surface: softbuffer::Surface<NoDisplayHandle, NoWindowHandle>,
canvas: HtmlCanvasElement,
ctx: CanvasRenderingContext2d,
rgba: Vec<u8>,
}

impl Canvas {
pub(crate) fn new(render_canvas: HtmlCanvasElement, width: NonZeroU32, height: NonZeroU32) -> anyhow::Result<Self> {
render_canvas.set_width(width.get());
render_canvas.set_height(height.get());
let ctx = context_2d(&render_canvas)?;

#[cfg(target_arch = "wasm32")]
let mut surface = {
use softbuffer::SurfaceExtWeb as _;
softbuffer::Surface::from_canvas(render_canvas).expect("surface")
};

#[cfg(not(target_arch = "wasm32"))]
let mut surface = {
fn stub(_: HtmlCanvasElement) -> softbuffer::Surface<NoDisplayHandle, NoWindowHandle> {
unimplemented!()
}

stub(render_canvas)
};

surface.resize(width, height).expect("surface resize");

Ok(Self { width, surface })
Ok(Self {
canvas: render_canvas,
ctx,
rgba: Vec::new(),
})
}

/// Setting width/height resets the canvas backing store; the 2D context persists.
Comment thread
irvingoujAtDevolution marked this conversation as resolved.
Outdated
pub(crate) fn resize(&mut self, width: NonZeroU32, height: NonZeroU32) {
self.surface.resize(width, height).expect("surface resize");
self.width = width;
self.canvas.set_width(width.get());
self.canvas.set_height(height.get());
Comment thread
irvingoujAtDevolution marked this conversation as resolved.
}

/// `buffer` is the region's RGBA sub-image (as produced by `extract_partial_image`).
pub(crate) fn draw(&mut self, buffer: &[u8], region: InclusiveRectangle) -> anyhow::Result<()> {
let region_width = region.width();
let region_height = region.height();

let mut src = buffer.chunks_exact(4).map(|pixel| {
let r = pixel[0];
let g = pixel[1];
let b = pixel[2];
u32::from_be_bytes([0, r, g, b])
});

let mut dst = self.surface.buffer_mut().expect("surface buffer");

{
// Copy src into dst

let region_top_usize = usize::from(region.top);
let region_height_usize = usize::from(region_height);
let region_left_usize = usize::from(region.left);
let region_width_usize = usize::from(region_width);

for dst_row in dst
.chunks_exact_mut(usize::try_from(self.width.get()).context("canvas width")?)
.skip(region_top_usize)
.take(region_height_usize)
{
let src_row = src.by_ref().take(region_width_usize);
let len = buffer.len();
if self.rgba.len() < len {
self.rgba.resize(len, 0);
}
let dst = &mut self.rgba[..len];
dst.copy_from_slice(buffer);
Comment thread
irvingoujAtDevolution marked this conversation as resolved.
Outdated

dst_row
.iter_mut()
.skip(region_left_usize)
.take(region_width_usize)
.zip(src_row)
.for_each(|(dst, src)| *dst = src);
}
// Force opaque alpha: most decode paths already write 0xFF, but the QOI path copies source
// alpha, and `put_image_data` stores alpha verbatim into the canvas.
for pixel in dst.chunks_exact_mut(4) {
pixel[3] = 0xFF;
}
Comment on lines +42 to 44

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: Just challenging the established pattern here: do we really need to force opaque alpha? I assume that if we don’t do that we end up with visual artifacts? My understanding is that we are just extracting a sub-image from an otherwise fully rendered image, and that we don’t really need any extra cleaning step.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Benoît Cortier (@CBenoit) yeah, works for most cases but it's not guaranteed. Framebuffer starts zero-filled (here) and the QOI-RGBA path keeps source alpha (here) — plus a tall update gets widened to full width, so early / post-resize frames can upload not-yet-painted columns as transparent.

Best case we'd guarantee opaque upstream (init the framebuffer to 0xff + clamp apply_rgba32), but for the scope of this PR I think it's better to keep the force and fix it in a follow-up. Sound good?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good to me! My only suggestion then is to track this with an issue for visibility


let damage_rect = softbuffer::Rect {
x: u32::from(region.left),
y: u32::from(region.top),
width: NonZeroU32::new(u32::from(region_width))
.expect("per InclusiveRectangle invariants: 0 < region_width"),
height: NonZeroU32::new(u32::from(region_height))
.expect("per InclusiveRectangle invariants: 0 < region_height"),
};
blit(&self.ctx, dst, &region)
}
}

dst.present_with_damage(&[damage_rect]).expect("buffer present");
/// Acquires the canvas 2D context. Only meaningful on wasm; stubbed elsewhere so the crate still
/// type-checks for host tooling.
fn context_2d(canvas: &HtmlCanvasElement) -> anyhow::Result<CanvasRenderingContext2d> {
#[cfg(target_arch = "wasm32")]
{
canvas
.get_context("2d")
.map_err(|err| anyhow!("get_context(\"2d\") failed: {err:?}"))?
.ok_or_else(|| anyhow!("canvas has no 2d context"))?
.dyn_into::<CanvasRenderingContext2d>()
.map_err(|_| anyhow!("2d context is not a CanvasRenderingContext2d"))
}
#[cfg(not(target_arch = "wasm32"))]
{
let _ = canvas;
unimplemented!("web canvas is only available on wasm32")
}
}

Ok(())
/// Blits `rgba` (a `region`-sized RGBA buffer) onto the canvas at the region's origin.
fn blit(ctx: &CanvasRenderingContext2d, rgba: &[u8], region: &InclusiveRectangle) -> anyhow::Result<()> {
#[cfg(target_arch = "wasm32")]
{
let image = ImageData::new_with_u8_clamped_array_and_sh(
Clamped(rgba),
u32::from(region.width()),
u32::from(region.height()),
)
.map_err(|err| anyhow!("ImageData::new failed: {err:?}"))?;
Comment thread
irvingoujAtDevolution marked this conversation as resolved.
Outdated
ctx.put_image_data(&image, f64::from(region.left), f64::from(region.top))
.map_err(|err| anyhow!("put_image_data failed: {err:?}"))
}
#[cfg(not(target_arch = "wasm32"))]
{
let _ = (ctx, rgba, region);
unimplemented!("web canvas is only available on wasm32")
}
}
Loading