Skip to content

Commit d3705af

Browse files
perf(web): replace softbuffer with direct put_image_data canvas present (#1374)
## Summary The web client presented frames through `softbuffer`, whose web backend repacks the **whole surface** (RGBA → u32 → RGBA into a fresh buffer) on every present. This replaces it with a direct `put_image_data` that uploads only the dirty region, and drops the `softbuffer` dependency. Same idea as the IronVNC change. ## What changed - Remove the `softbuffer` dependency; present each dirty region with `put_image_data` at its origin. - No full-surface buffer and no per-region scratch. `extract_partial_image` fills a single `WriteBuf` reused across frames, so steady-state draws don't allocate. - Force opaque alpha before upload (kept — see Correctness). - Add `WriteBuf::filled_mut` to `ironrdp-core` (mutable counterpart of `filled`). - `web-sys`: add `CanvasRenderingContext2d` + `ImageData`, drop the softbuffer-only features. ## Performance Draw-stage time on a 1080p replay (595 frames / 110 dirty regions), headless Chromium, 8 measured passes × 3 runs, median. Both rows are reproducible branches off the replay-bench harness; the only difference is the render path. | Render path | draw (ms) | vs softbuffer | branch | |---|--:|--:|---| | softbuffer `present_with_damage` | ~1031 | — | `bench/draw-softbuffer` | | this PR (direct upload, reused `WriteBuf`) | ~97 | **~10.6×** | `bench/draw-zerocopy` | - The win is structural: upload the dirty region instead of repacking the whole surface every present. - Reusing one `WriteBuf` (vs a per-frame allocation) keeps the steady-state draw allocation-free; the remaining cost is the unavoidable `ImageData` JS copy. - Output is **byte-identical**: framebuffer CRC32 `2d8e1b79` matches the recorded ground truth and the rendered-canvas FNV-1a is unchanged. - Absolute ms carry ~±15% noise from machine load (decode drifted 1.5–1.9 s); the ratio held across runs. Reproduce: ```sh git checkout bench/draw-softbuffer # or bench/draw-zerocopy cd crates/ironrdp-web && wasm-pack build --target web --release -- --features bench cd bench-harness && node run.mjs --capture /bench-corpus/<your>.irdprec --passes 8 ``` ## Correctness `put_image_data` stores alpha verbatim, and the decoded framebuffer isn't guaranteed opaque — it's zero-initialised, a widened whole-rows region can cover not-yet-painted columns (alpha 0), and the QOI-RGBA path copies source alpha. So we force alpha opaque before upload. A scan-then-conditionally-force was tried and is *slower* than just forcing (the check touches the same bytes), so the unconditional force stays. ## Follow-up (separate PR) Guarantee framebuffer opacity upstream in `ironrdp-session` (init alpha to `0xff` + clamp `apply_rgba32`); after that the web side can drop the alpha force entirely.
1 parent 45ec1ef commit d3705af

6 files changed

Lines changed: 108 additions & 93 deletions

File tree

Cargo.lock

Lines changed: 0 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/ironrdp-core/src/write_buf.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,12 @@ impl WriteBuf {
6262
&self.inner[..self.filled]
6363
}
6464

65+
/// Returns a mutable reference to the filled portion of the buffer.
66+
#[inline]
67+
pub fn filled_mut(&mut self) -> &mut [u8] {
68+
&mut self.inner[..self.filled]
69+
}
70+
6571
/// Ensures initialized and unfilled portion of the buffer is big enough for `additional` more bytes.
6672
#[inline]
6773
pub fn initialize(&mut self, additional: usize) {

crates/ironrdp-web/Cargo.toml

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,13 +51,19 @@ iron-remote-desktop.path = "../iron-remote-desktop"
5151
# WASM
5252
wasm-bindgen = "0.2"
5353
wasm-bindgen-futures = "0.4"
54-
web-sys = { version = "0.3", features = ["HtmlCanvasElement", "Navigator", "Performance", "Window"] }
54+
web-sys = { version = "0.3", features = [
55+
"CanvasRenderingContext2d",
56+
"HtmlCanvasElement",
57+
"ImageData",
58+
"Navigator",
59+
"Performance",
60+
"Window",
61+
] }
5562
js-sys = "0.3"
5663
gloo-net = { version = "0.7", default-features = false, features = ["websocket", "http", "io-util"] }
5764
gloo-timers = { version = "0.4", default-features = false, features = ["futures"] }
5865

5966
# Rendering
60-
softbuffer = { version = "0.4", default-features = false }
6167
png = "0.18"
6268
resize = { version = "0.8", features = ["std"], default-features = false }
6369
rgb = "0.8"

crates/ironrdp-web/src/canvas.rs

Lines changed: 61 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,93 +1,82 @@
11
use core::num::NonZeroU32;
22

3-
use anyhow::Context as _;
4-
use ironrdp::pdu::geometry::{InclusiveRectangle, Rectangle as _};
5-
use softbuffer::{NoDisplayHandle, NoWindowHandle};
6-
use web_sys::HtmlCanvasElement;
7-
3+
#[cfg(target_arch = "wasm32")]
4+
use anyhow::anyhow;
5+
use ironrdp::pdu::geometry::InclusiveRectangle;
6+
#[cfg(target_arch = "wasm32")]
7+
use ironrdp::pdu::geometry::Rectangle as _;
8+
#[cfg(target_arch = "wasm32")]
9+
use wasm_bindgen::{Clamped, JsCast as _};
10+
#[cfg(target_arch = "wasm32")]
11+
use web_sys::ImageData;
12+
use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement};
13+
14+
/// Web render surface: blits each dirty region to the canvas with `put_image_data`.
815
pub(crate) struct Canvas {
9-
width: NonZeroU32,
10-
surface: softbuffer::Surface<NoDisplayHandle, NoWindowHandle>,
16+
canvas: HtmlCanvasElement,
17+
ctx: CanvasRenderingContext2d,
1118
}
1219

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

18-
#[cfg(target_arch = "wasm32")]
19-
let mut surface = {
20-
use softbuffer::SurfaceExtWeb as _;
21-
softbuffer::Surface::from_canvas(render_canvas).expect("surface")
22-
};
23-
24-
#[cfg(not(target_arch = "wasm32"))]
25-
let mut surface = {
26-
fn stub(_: HtmlCanvasElement) -> softbuffer::Surface<NoDisplayHandle, NoWindowHandle> {
27-
unimplemented!()
28-
}
29-
30-
stub(render_canvas)
31-
};
32-
33-
surface.resize(width, height).expect("surface resize");
34-
35-
Ok(Self { width, surface })
26+
Ok(Self {
27+
canvas: render_canvas,
28+
ctx,
29+
})
3630
}
3731

32+
/// Resizes the backing store. Note: this also clears the canvas and resets 2D context state;
33+
/// the cached `ctx` stays valid.
3834
pub(crate) fn resize(&mut self, width: NonZeroU32, height: NonZeroU32) {
39-
self.surface.resize(width, height).expect("surface resize");
40-
self.width = width;
35+
self.canvas.set_width(width.get());
36+
self.canvas.set_height(height.get());
4137
}
4238

43-
pub(crate) fn draw(&mut self, buffer: &[u8], region: InclusiveRectangle) -> anyhow::Result<()> {
44-
let region_width = region.width();
45-
let region_height = region.height();
46-
47-
let mut src = buffer.chunks_exact(4).map(|pixel| {
48-
let r = pixel[0];
49-
let g = pixel[1];
50-
let b = pixel[2];
51-
u32::from_be_bytes([0, r, g, b])
52-
});
53-
54-
let mut dst = self.surface.buffer_mut().expect("surface buffer");
39+
/// Blits a dirty region with `put_image_data`. Forces alpha opaque first: the framebuffer isn't
40+
/// guaranteed opaque (zero-init columns, QOI-RGBA) and `put_image_data` stores alpha verbatim.
41+
pub(crate) fn draw(&self, buffer: &mut [u8], region: InclusiveRectangle) -> anyhow::Result<()> {
42+
for pixel in buffer.chunks_exact_mut(4) {
43+
pixel[3] = 0xFF;
44+
}
5545

46+
#[cfg(target_arch = "wasm32")]
5647
{
57-
// Copy src into dst
58-
59-
let region_top_usize = usize::from(region.top);
60-
let region_height_usize = usize::from(region_height);
61-
let region_left_usize = usize::from(region.left);
62-
let region_width_usize = usize::from(region_width);
63-
64-
for dst_row in dst
65-
.chunks_exact_mut(usize::try_from(self.width.get()).context("canvas width")?)
66-
.skip(region_top_usize)
67-
.take(region_height_usize)
68-
{
69-
let src_row = src.by_ref().take(region_width_usize);
70-
71-
dst_row
72-
.iter_mut()
73-
.skip(region_left_usize)
74-
.take(region_width_usize)
75-
.zip(src_row)
76-
.for_each(|(dst, src)| *dst = src);
77-
}
48+
let image = ImageData::new_with_u8_clamped_array_and_sh(
49+
Clamped(&*buffer),
50+
u32::from(region.width()),
51+
u32::from(region.height()),
52+
)
53+
.map_err(|err| anyhow!("ImageData::new failed: {err:?}"))?;
54+
self.ctx
55+
.put_image_data(&image, f64::from(region.left), f64::from(region.top))
56+
.map_err(|err| anyhow!("put_image_data failed: {err:?}"))
7857
}
58+
#[cfg(not(target_arch = "wasm32"))]
59+
{
60+
let _ = (&self.ctx, buffer, region);
61+
unimplemented!("web canvas is only available on wasm32")
62+
}
63+
}
64+
}
7965

80-
let damage_rect = softbuffer::Rect {
81-
x: u32::from(region.left),
82-
y: u32::from(region.top),
83-
width: NonZeroU32::new(u32::from(region_width))
84-
.expect("per InclusiveRectangle invariants: 0 < region_width"),
85-
height: NonZeroU32::new(u32::from(region_height))
86-
.expect("per InclusiveRectangle invariants: 0 < region_height"),
87-
};
88-
89-
dst.present_with_damage(&[damage_rect]).expect("buffer present");
90-
91-
Ok(())
66+
/// Acquires the canvas 2D context (wasm only; panics on other targets).
67+
fn context_2d(canvas: &HtmlCanvasElement) -> anyhow::Result<CanvasRenderingContext2d> {
68+
#[cfg(target_arch = "wasm32")]
69+
{
70+
canvas
71+
.get_context("2d")
72+
.map_err(|err| anyhow!("get_context(\"2d\") failed: {err:?}"))?
73+
.ok_or_else(|| anyhow!("canvas has no 2d context"))?
74+
.dyn_into::<CanvasRenderingContext2d>()
75+
.map_err(|_| anyhow!("2d context is not a CanvasRenderingContext2d"))
76+
}
77+
#[cfg(not(target_arch = "wasm32"))]
78+
{
79+
let _ = canvas;
80+
unimplemented!("web canvas is only available on wasm32")
9281
}
9382
}

crates/ironrdp-web/src/image.rs

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,29 @@
22

33
use ironrdp::pdu::geometry::{InclusiveRectangle, Rectangle as _};
44
use ironrdp::session::image::DecodedImage;
5-
6-
pub(crate) fn extract_partial_image(image: &DecodedImage, region: InclusiveRectangle) -> (InclusiveRectangle, Vec<u8>) {
5+
use ironrdp_core::WriteBuf;
6+
7+
/// Copies the dirty `region` into `buffer` from its current cursor (clear it between regions).
8+
/// The returned rect may be wider than `region`: the whole-rows path widens to full image width.
9+
pub(crate) fn extract_partial_image(
10+
image: &DecodedImage,
11+
region: InclusiveRectangle,
12+
buffer: &mut WriteBuf,
13+
) -> InclusiveRectangle {
714
// PERF: needs actual benchmark to find a better heuristic
815
if region.height() > 64 || region.width() > 512 {
9-
extract_whole_rows(image, region)
16+
extract_whole_rows(image, region, buffer)
1017
} else {
11-
extract_smallest_rectangle(image, region)
18+
extract_smallest_rectangle(image, region, buffer)
1219
}
1320
}
1421

1522
// Faster for low-height and smaller images
16-
fn extract_smallest_rectangle(image: &DecodedImage, region: InclusiveRectangle) -> (InclusiveRectangle, Vec<u8>) {
23+
fn extract_smallest_rectangle(
24+
image: &DecodedImage,
25+
region: InclusiveRectangle,
26+
buffer: &mut WriteBuf,
27+
) -> InclusiveRectangle {
1728
let pixel_size = usize::from(image.pixel_format().bytes_per_pixel());
1829

1930
let image_width = usize::from(image.width());
@@ -26,7 +37,7 @@ fn extract_smallest_rectangle(image: &DecodedImage, region: InclusiveRectangle)
2637
let region_stride = region_width * pixel_size;
2738

2839
let dst_buf_size = region_width * region_height * pixel_size;
29-
let mut dst = vec![0; dst_buf_size];
40+
let dst = buffer.unfilled_to(dst_buf_size);
3041

3142
let src = image.data();
3243

@@ -42,11 +53,13 @@ fn extract_smallest_rectangle(image: &DecodedImage, region: InclusiveRectangle)
4253
target_slice.copy_from_slice(src_slice);
4354
}
4455

45-
(region, dst)
56+
buffer.advance(dst_buf_size);
57+
58+
region
4659
}
4760

4861
// Faster for high-height and bigger images
49-
fn extract_whole_rows(image: &DecodedImage, region: InclusiveRectangle) -> (InclusiveRectangle, Vec<u8>) {
62+
fn extract_whole_rows(image: &DecodedImage, region: InclusiveRectangle, buffer: &mut WriteBuf) -> InclusiveRectangle {
5063
let pixel_size = usize::from(image.pixel_format().bytes_per_pixel());
5164

5265
let image_width = usize::from(image.width());
@@ -59,15 +72,15 @@ fn extract_whole_rows(image: &DecodedImage, region: InclusiveRectangle) -> (Incl
5972

6073
let src_begin = region_top * image_stride;
6174
let src_end = (region_bottom + 1) * image_stride;
75+
let len = src_end - src_begin;
6276

63-
let dst = src[src_begin..src_end].to_vec();
77+
buffer.unfilled_to(len).copy_from_slice(&src[src_begin..src_end]);
78+
buffer.advance(len);
6479

65-
let wider_region = InclusiveRectangle {
80+
InclusiveRectangle {
6681
left: 0,
6782
top: region.top,
6883
right: image.width() - 1,
6984
bottom: region.bottom,
70-
};
71-
72-
(wider_region, dst)
85+
}
7386
}

crates/ironrdp-web/src/session.rs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -657,6 +657,9 @@ impl iron_remote_desktop::Session for Session {
657657

658658
let mut requested_resize = None;
659659

660+
// Reused across frames so per-region extraction doesn't allocate on every draw.
661+
let mut draw_buffer = WriteBuf::new();
662+
660663
let mut active_stage = ActiveStage::new(connection_result);
661664

662665
// Timer interval for driving clipboard lock timeouts (5 second interval)
@@ -875,9 +878,10 @@ impl iron_remote_desktop::Session for Session {
875878
.context("Send frame to writer task")?;
876879
}
877880
ActiveStageOutput::GraphicsUpdate(region) => {
878-
// PERF: some copies and conversion could be optimized
879-
let (region, buffer) = extract_partial_image(&image, region);
880-
gui.draw(&buffer, region).context("draw updated region")?;
881+
let region = extract_partial_image(&image, region, &mut draw_buffer);
882+
gui.draw(draw_buffer.filled_mut(), region)
883+
.context("draw updated region")?;
884+
draw_buffer.clear();
881885
}
882886
ActiveStageOutput::PointerDefault => {
883887
self.set_cursor_style(CursorStyle::Default)?;
@@ -987,8 +991,6 @@ impl iron_remote_desktop::Session for Session {
987991
// We need to perform resize after receiving the Deactivate All PDU, because there may be frames
988992
// with the previous dimensions arriving between the resize request and this message.
989993
if let Some((width, height)) = requested_resize {
990-
self.render_canvas.set_width(width.get());
991-
self.render_canvas.set_height(height.get());
992994
gui.resize(width, height);
993995
requested_resize = None;
994996
}

0 commit comments

Comments
 (0)