perf: RDP dirty rectangle tracking — partial frame transfer
Some checks failed
Build & Sign Wraith / Build Windows + Sign (push) Failing after 7s

Root cause: Every GraphicsUpdate copied the full 8.3MB decoded image
into the front buffer, cloned all 8.3MB for IPC, and transferred all
8.3MB to the frontend — even when only a 100x100 pixel region changed.
During window drag, this created a 25MB/frame pipeline backup.

Fix:
- Track dirty rectangles from ironrdp's GraphicsUpdate(InclusiveRectangle)
- Write path: only copy changed rows from decoded image to front buffer
  (e.g. 100 rows × 1920 pixels = 768KB vs 8.3MB full frame)
- Accumulate dirty region as union of all rects since last get_frame
- Read path: if dirty region < 50% of frame, extract only the dirty
  rectangle bytes; otherwise fall back to full frame
- Binary IPC format: 8-byte header [x,y,w,h as u16 LE] + pixel data
- Frontend: putImageData at dirty rect offset instead of full frame
- Status bar: h-9 text-sm for 3440x1440 readability

During window drag (typical 300x400 dirty rect):
  Before: 8.3MB write + 8.3MB clone + 8.3MB IPC = 24.9MB per frame
  After:  480KB write + 480KB extract + 480KB IPC = 1.4MB per frame
  ~17x reduction in data movement per frame.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-30 10:38:26 -04:00
parent 38cb1f7430
commit 48f9af0824
4 changed files with 142 additions and 43 deletions

View File

@ -19,18 +19,37 @@ pub fn connect_rdp(
state.rdp.connect(config, app_handle) state.rdp.connect(config, app_handle)
} }
/// Get the current frame buffer as raw RGBA bytes via binary IPC. /// Get the dirty region since last call as raw RGBA bytes via binary IPC.
/// ///
/// Uses `tauri::ipc::Response` to return raw bytes without JSON serialization. /// Binary format: 8-byte header + pixel data
/// Pixel format: RGBA, 4 bytes per pixel, row-major, top-left origin. /// Header: [x: u16, y: u16, width: u16, height: u16] (little-endian)
/// Returns empty payload if frame hasn't changed since last call. /// If header is all zeros, the payload is a full frame (width*height*4 bytes).
/// If header is non-zero, payload contains only the dirty rectangle pixels.
/// Returns empty payload if nothing changed.
#[tauri::command] #[tauri::command]
pub fn rdp_get_frame( pub fn rdp_get_frame(
session_id: String, session_id: String,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<Response, String> { ) -> Result<Response, String> {
let frame = state.rdp.get_frame(&session_id)?; let (region, pixels) = state.rdp.get_frame(&session_id)?;
Ok(Response::new(frame)) if pixels.is_empty() {
return Ok(Response::new(Vec::new()));
}
// Prepend 8-byte dirty rect header
let mut out = Vec::with_capacity(8 + pixels.len());
match region {
Some(rect) => {
out.extend_from_slice(&rect.x.to_le_bytes());
out.extend_from_slice(&rect.y.to_le_bytes());
out.extend_from_slice(&rect.width.to_le_bytes());
out.extend_from_slice(&rect.height.to_le_bytes());
}
None => {
out.extend_from_slice(&[0u8; 8]); // full frame marker
}
}
out.extend_from_slice(&pixels);
Ok(Response::new(out))
} }
/// Send a mouse event to an RDP session. /// Send a mouse event to an RDP session.

View File

@ -66,14 +66,25 @@ enum InputEvent {
Disconnect, Disconnect,
} }
/// Dirty rectangle from the last GraphicsUpdate — used for partial frame transfer.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DirtyRect {
pub x: u16,
pub y: u16,
pub width: u16,
pub height: u16,
}
struct RdpSessionHandle { struct RdpSessionHandle {
id: String, id: String,
hostname: String, hostname: String,
width: u16, width: u16,
height: u16, height: u16,
/// Frame buffer: RDP thread writes via RwLock write, IPC reads via RwLock read. /// Frame buffer: RDP thread writes via RwLock write, IPC reads via RwLock read.
/// Brief write-lock per GraphicsUpdate, concurrent reads for get_frame.
front_buffer: Arc<std::sync::RwLock<Vec<u8>>>, front_buffer: Arc<std::sync::RwLock<Vec<u8>>>,
/// Accumulated dirty region since last get_frame — union of all GraphicsUpdate rects.
dirty_region: Arc<std::sync::Mutex<Option<DirtyRect>>>,
frame_dirty: Arc<AtomicBool>, frame_dirty: Arc<AtomicBool>,
input_tx: mpsc::UnboundedSender<InputEvent>, input_tx: mpsc::UnboundedSender<InputEvent>,
} }
@ -102,6 +113,7 @@ impl RdpService {
pixel[3] = 255; pixel[3] = 255;
} }
let front_buffer = Arc::new(std::sync::RwLock::new(initial_buf)); let front_buffer = Arc::new(std::sync::RwLock::new(initial_buf));
let dirty_region = Arc::new(std::sync::Mutex::new(None));
let frame_dirty = Arc::new(AtomicBool::new(false)); let frame_dirty = Arc::new(AtomicBool::new(false));
let (input_tx, input_rx) = mpsc::unbounded_channel(); let (input_tx, input_rx) = mpsc::unbounded_channel();
@ -112,6 +124,7 @@ impl RdpService {
width, width,
height, height,
front_buffer: front_buffer.clone(), front_buffer: front_buffer.clone(),
dirty_region: dirty_region.clone(),
frame_dirty: frame_dirty.clone(), frame_dirty: frame_dirty.clone(),
input_tx, input_tx,
}); });
@ -159,6 +172,7 @@ impl RdpService {
connection_result, connection_result,
framed, framed,
front_buffer, front_buffer,
dirty_region,
frame_dirty, frame_dirty,
input_rx, input_rx,
width as u16, width as u16,
@ -202,13 +216,43 @@ impl RdpService {
Ok(session_id) Ok(session_id)
} }
pub fn get_frame(&self, session_id: &str) -> Result<Vec<u8>, String> { /// Get the dirty region since the last call. Returns (region_metadata, pixel_bytes).
/// The pixel bytes contain only the dirty rectangle in row-major RGBA order.
/// If nothing changed, returns empty bytes. If the dirty region covers >50% of the
/// frame, falls back to full frame for efficiency (avoids row-by-row extraction).
pub fn get_frame(&self, session_id: &str) -> Result<(Option<DirtyRect>, Vec<u8>), String> {
let handle = self.sessions.get(session_id).ok_or_else(|| format!("RDP session {} not found", session_id))?; let handle = self.sessions.get(session_id).ok_or_else(|| format!("RDP session {} not found", session_id))?;
if !handle.frame_dirty.swap(false, Ordering::Acquire) { if !handle.frame_dirty.swap(false, Ordering::Acquire) {
return Ok(Vec::new()); // No change — return empty return Ok((None, Vec::new()));
} }
let region = handle.dirty_region.lock().unwrap_or_else(|e| e.into_inner()).take();
let buf = handle.front_buffer.read().unwrap_or_else(|e| e.into_inner()); let buf = handle.front_buffer.read().unwrap_or_else(|e| e.into_inner());
Ok(buf.clone()) let stride = handle.width as usize * 4;
let total_pixels = handle.width as usize * handle.height as usize;
match region {
Some(rect) if (rect.width as usize * rect.height as usize) < total_pixels / 2 => {
// Partial: extract only the dirty rectangle
let rw = rect.width as usize;
let rh = rect.height as usize;
let rx = rect.x as usize;
let ry = rect.y as usize;
let mut out = Vec::with_capacity(rw * rh * 4);
for row in ry..ry + rh {
let start = row * stride + rx * 4;
let end = start + rw * 4;
if end <= buf.len() {
out.extend_from_slice(&buf[start..end]);
}
}
Ok((Some(rect), out))
}
_ => {
// Full frame: dirty region covers most of the screen or is missing
Ok((None, buf.clone()))
}
}
} }
pub fn get_frame_raw(&self, session_id: &str) -> Result<Vec<u8>, String> { pub fn get_frame_raw(&self, session_id: &str) -> Result<Vec<u8>, String> {
@ -342,7 +386,7 @@ async fn establish_connection(config: connector::Config, hostname: &str, port: u
Ok((connection_result, upgraded_framed)) Ok((connection_result, upgraded_framed))
} }
async fn run_active_session(connection_result: ConnectionResult, framed: UpgradedFramed, front_buffer: Arc<std::sync::RwLock<Vec<u8>>>, frame_dirty: Arc<AtomicBool>, mut input_rx: mpsc::UnboundedReceiver<InputEvent>, width: u16, height: u16, app_handle: tauri::AppHandle, session_id: String) -> Result<(), String> { async fn run_active_session(connection_result: ConnectionResult, framed: UpgradedFramed, front_buffer: Arc<std::sync::RwLock<Vec<u8>>>, dirty_region: Arc<std::sync::Mutex<Option<DirtyRect>>>, frame_dirty: Arc<AtomicBool>, mut input_rx: mpsc::UnboundedReceiver<InputEvent>, width: u16, height: u16, app_handle: tauri::AppHandle, session_id: String) -> Result<(), String> {
let (mut reader, mut writer) = split_tokio_framed(framed); let (mut reader, mut writer) = split_tokio_framed(framed);
let mut image = DecodedImage::new(PixelFormat::RgbA32, width, height); let mut image = DecodedImage::new(PixelFormat::RgbA32, width, height);
let mut active_stage = ActiveStage::new(connection_result); let mut active_stage = ActiveStage::new(connection_result);
@ -400,16 +444,43 @@ async fn run_active_session(connection_result: ConnectionResult, framed: Upgrade
for out in outputs { for out in outputs {
match out { match out {
ActiveStageOutput::ResponseFrame(frame) => { writer.write_all(&frame).await.map_err(|e| format!("Failed to write RDP response frame: {}", e))?; } ActiveStageOutput::ResponseFrame(frame) => { writer.write_all(&frame).await.map_err(|e| format!("Failed to write RDP response frame: {}", e))?; }
ActiveStageOutput::GraphicsUpdate(_region) => { ActiveStageOutput::GraphicsUpdate(region) => {
// Write decoded image directly to front buffer. let rx = region.left as usize;
// Single RwLock write — readers use read lock, no contention. let ry = region.top as usize;
let rr = (region.right as usize).saturating_add(1).min(width as usize);
let rb = (region.bottom as usize).saturating_add(1).min(height as usize);
let stride = width as usize * 4;
// Copy only the dirty rectangle rows from decoded image → front buffer
{ {
let src = image.data(); let src = image.data();
let mut front = front_buffer.write().unwrap_or_else(|e| e.into_inner()); let mut front = front_buffer.write().unwrap_or_else(|e| e.into_inner());
if src.len() == front.len() { front.copy_from_slice(src); } else { *front = src.to_vec(); } for row in ry..rb {
let src_start = row * stride + rx * 4;
let src_end = row * stride + rr * 4;
if src_end <= src.len() && src_end <= front.len() {
front[src_start..src_end].copy_from_slice(&src[src_start..src_end]);
} }
}
}
// Accumulate dirty region (union of all rects since last get_frame)
{
let new_rect = DirtyRect { x: rx as u16, y: ry as u16, width: (rr - rx) as u16, height: (rb - ry) as u16 };
let mut dr = dirty_region.lock().unwrap_or_else(|e| e.into_inner());
*dr = Some(match dr.take() {
None => new_rect,
Some(prev) => {
let x = prev.x.min(new_rect.x);
let y = prev.y.min(new_rect.y);
let r = (prev.x + prev.width).max(new_rect.x + new_rect.width);
let b = (prev.y + prev.height).max(new_rect.y + new_rect.height);
DirtyRect { x, y, width: r - x, height: b - y }
}
});
}
frame_dirty.store(true, Ordering::Release); frame_dirty.store(true, Ordering::Release);
// Signal frontend — rAF coalescing prevents flood
let _ = app_handle.emit(&format!("rdp:frame:{}", session_id), ()); let _ = app_handle.emit(&format!("rdp:frame:{}", session_id), ());
} }
ActiveStageOutput::Terminate(reason) => { info!("RDP session terminated: {:?}", reason); return Ok(()); } ActiveStageOutput::Terminate(reason) => { info!("RDP session terminated: {:?}", reason); return Ok(()); }

View File

@ -1,5 +1,5 @@
<template> <template>
<div class="h-8 flex items-center justify-between px-4 bg-[var(--wraith-bg-secondary)] border-t border-[var(--wraith-border)] text-xs text-[var(--wraith-text-muted)] shrink-0"> <div class="h-9 flex items-center justify-between px-4 bg-[var(--wraith-bg-secondary)] border-t border-[var(--wraith-border)] text-sm text-[var(--wraith-text-muted)] shrink-0">
<!-- Left: connection info --> <!-- Left: connection info -->
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<template v-if="sessionStore.activeSession"> <template v-if="sessionStore.activeSession">

View File

@ -158,8 +158,8 @@ export interface UseRdpReturn {
keyboardGrabbed: Ref<boolean>; keyboardGrabbed: Ref<boolean>;
/** Whether clipboard sync is enabled */ /** Whether clipboard sync is enabled */
clipboardSync: Ref<boolean>; clipboardSync: Ref<boolean>;
/** Fetch the current frame as RGBA ImageData */ /** Fetch and render the dirty region directly to a canvas context */
fetchFrame: (sessionId: string, width: number, height: number) => Promise<ImageData | null>; fetchAndRender: (sessionId: string, width: number, height: number, ctx: CanvasRenderingContext2D) => Promise<boolean>;
/** Send a mouse event to the backend */ /** Send a mouse event to the backend */
sendMouse: (sessionId: string, x: number, y: number, flags: number) => void; sendMouse: (sessionId: string, x: number, y: number, flags: number) => void;
/** Send a key event to the backend */ /** Send a key event to the backend */
@ -199,38 +199,50 @@ export function useRdp(): UseRdpReturn {
let unlistenFrame: (() => void) | null = null; let unlistenFrame: (() => void) | null = null;
/** /**
* Fetch the current frame from the Rust RDP backend. * Fetch the dirty region from the Rust RDP backend and apply it to the canvas.
* *
* rdp_get_frame returns raw RGBA bytes (width*height*4) serialised as a * Binary format from backend: 8-byte header + pixel data
* base64 string over Tauri's IPC bridge. We decode it to Uint8ClampedArray * Header: [x: u16, y: u16, w: u16, h: u16] (little-endian)
* and wrap in an ImageData for putImageData(). * If header is all zeros full frame (width*height*4 bytes)
* If header is non-zero dirty rectangle (w*h*4 bytes)
*
* Returns true if a frame was rendered, false if nothing changed.
*/ */
async function fetchFrame( async function fetchAndRender(
sessionId: string, sessionId: string,
width: number, width: number,
height: number, height: number,
): Promise<ImageData | null> { ctx: CanvasRenderingContext2D,
): Promise<boolean> {
let raw: ArrayBuffer; let raw: ArrayBuffer;
try { try {
raw = await invoke<ArrayBuffer>("rdp_get_frame", { sessionId }); raw = await invoke<ArrayBuffer>("rdp_get_frame", { sessionId });
} catch { } catch {
return null; return false;
} }
if (!raw || raw.byteLength === 0) return null; if (!raw || raw.byteLength <= 8) return false;
// Binary IPC — tauri::ipc::Response delivers raw bytes as ArrayBuffer const view = new DataView(raw);
const bytes = new Uint8ClampedArray(raw); const rx = view.getUint16(0, true);
const ry = view.getUint16(2, true);
const rw = view.getUint16(4, true);
const rh = view.getUint16(6, true);
const pixelData = new Uint8ClampedArray(raw, 8);
if (rx === 0 && ry === 0 && rw === 0 && rh === 0) {
// Full frame
const expected = width * height * 4; const expected = width * height * 4;
if (bytes.length !== expected) { if (pixelData.length !== expected) return false;
console.warn( ctx.putImageData(new ImageData(pixelData, width, height), 0, 0);
`[useRdp] Frame size mismatch: got ${bytes.length}, expected ${expected}`, } else {
); // Dirty rectangle — apply at offset
return null; const expected = rw * rh * 4;
if (pixelData.length !== expected) return false;
ctx.putImageData(new ImageData(pixelData, rw, rh), rx, ry);
} }
return new ImageData(bytes, width, height); return true;
} }
/** /**
@ -301,7 +313,7 @@ export function useRdp(): UseRdpReturn {
let fetchPending = false; let fetchPending = false;
let rafScheduled = false; let rafScheduled = false;
// Fetch frame when backend signals a new frame is ready. // Fetch and render dirty region when backend signals new frame data.
// Uses rAF to coalesce rapid events into one fetch per display frame. // Uses rAF to coalesce rapid events into one fetch per display frame.
function scheduleFrameFetch(): void { function scheduleFrameFetch(): void {
if (rafScheduled) return; if (rafScheduled) return;
@ -310,12 +322,9 @@ export function useRdp(): UseRdpReturn {
rafScheduled = false; rafScheduled = false;
if (fetchPending) return; if (fetchPending) return;
fetchPending = true; fetchPending = true;
const imageData = await fetchFrame(sessionId, width, height); const rendered = await fetchAndRender(sessionId, width, height, ctx);
fetchPending = false; fetchPending = false;
if (imageData && ctx) { if (rendered && !connected.value) connected.value = true;
ctx.putImageData(imageData, 0, 0);
if (!connected.value) connected.value = true;
}
}); });
} }
@ -363,7 +372,7 @@ export function useRdp(): UseRdpReturn {
connected, connected,
keyboardGrabbed, keyboardGrabbed,
clipboardSync, clipboardSync,
fetchFrame, fetchAndRender,
sendMouse, sendMouse,
sendKey, sendKey,
sendClipboard, sendClipboard,