perf: RDP dirty rectangle tracking — partial frame transfer
Some checks failed
Build & Sign Wraith / Build Windows + Sign (push) Failing after 7s
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:
parent
38cb1f7430
commit
48f9af0824
@ -19,18 +19,37 @@ pub fn connect_rdp(
|
||||
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.
|
||||
/// Pixel format: RGBA, 4 bytes per pixel, row-major, top-left origin.
|
||||
/// Returns empty payload if frame hasn't changed since last call.
|
||||
/// Binary format: 8-byte header + pixel data
|
||||
/// Header: [x: u16, y: u16, width: u16, height: u16] (little-endian)
|
||||
/// 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]
|
||||
pub fn rdp_get_frame(
|
||||
session_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Response, String> {
|
||||
let frame = state.rdp.get_frame(&session_id)?;
|
||||
Ok(Response::new(frame))
|
||||
let (region, pixels) = state.rdp.get_frame(&session_id)?;
|
||||
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.
|
||||
|
||||
@ -66,14 +66,25 @@ enum InputEvent {
|
||||
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 {
|
||||
id: String,
|
||||
hostname: String,
|
||||
width: u16,
|
||||
height: u16,
|
||||
/// 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>>>,
|
||||
/// Accumulated dirty region since last get_frame — union of all GraphicsUpdate rects.
|
||||
dirty_region: Arc<std::sync::Mutex<Option<DirtyRect>>>,
|
||||
frame_dirty: Arc<AtomicBool>,
|
||||
input_tx: mpsc::UnboundedSender<InputEvent>,
|
||||
}
|
||||
@ -102,6 +113,7 @@ impl RdpService {
|
||||
pixel[3] = 255;
|
||||
}
|
||||
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 (input_tx, input_rx) = mpsc::unbounded_channel();
|
||||
@ -112,6 +124,7 @@ impl RdpService {
|
||||
width,
|
||||
height,
|
||||
front_buffer: front_buffer.clone(),
|
||||
dirty_region: dirty_region.clone(),
|
||||
frame_dirty: frame_dirty.clone(),
|
||||
input_tx,
|
||||
});
|
||||
@ -159,6 +172,7 @@ impl RdpService {
|
||||
connection_result,
|
||||
framed,
|
||||
front_buffer,
|
||||
dirty_region,
|
||||
frame_dirty,
|
||||
input_rx,
|
||||
width as u16,
|
||||
@ -202,13 +216,43 @@ impl RdpService {
|
||||
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))?;
|
||||
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());
|
||||
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> {
|
||||
@ -342,7 +386,7 @@ async fn establish_connection(config: connector::Config, hostname: &str, port: u
|
||||
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 image = DecodedImage::new(PixelFormat::RgbA32, width, height);
|
||||
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 {
|
||||
match out {
|
||||
ActiveStageOutput::ResponseFrame(frame) => { writer.write_all(&frame).await.map_err(|e| format!("Failed to write RDP response frame: {}", e))?; }
|
||||
ActiveStageOutput::GraphicsUpdate(_region) => {
|
||||
// Write decoded image directly to front buffer.
|
||||
// Single RwLock write — readers use read lock, no contention.
|
||||
ActiveStageOutput::GraphicsUpdate(region) => {
|
||||
let rx = region.left as usize;
|
||||
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 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);
|
||||
// Signal frontend — rAF coalescing prevents flood
|
||||
let _ = app_handle.emit(&format!("rdp:frame:{}", session_id), ());
|
||||
}
|
||||
ActiveStageOutput::Terminate(reason) => { info!("RDP session terminated: {:?}", reason); return Ok(()); }
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<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 -->
|
||||
<div class="flex items-center gap-3">
|
||||
<template v-if="sessionStore.activeSession">
|
||||
|
||||
@ -158,8 +158,8 @@ export interface UseRdpReturn {
|
||||
keyboardGrabbed: Ref<boolean>;
|
||||
/** Whether clipboard sync is enabled */
|
||||
clipboardSync: Ref<boolean>;
|
||||
/** Fetch the current frame as RGBA ImageData */
|
||||
fetchFrame: (sessionId: string, width: number, height: number) => Promise<ImageData | null>;
|
||||
/** Fetch and render the dirty region directly to a canvas context */
|
||||
fetchAndRender: (sessionId: string, width: number, height: number, ctx: CanvasRenderingContext2D) => Promise<boolean>;
|
||||
/** Send a mouse event to the backend */
|
||||
sendMouse: (sessionId: string, x: number, y: number, flags: number) => void;
|
||||
/** Send a key event to the backend */
|
||||
@ -199,38 +199,50 @@ export function useRdp(): UseRdpReturn {
|
||||
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
|
||||
* base64 string over Tauri's IPC bridge. We decode it to Uint8ClampedArray
|
||||
* and wrap in an ImageData for putImageData().
|
||||
* Binary format from backend: 8-byte header + pixel data
|
||||
* Header: [x: u16, y: u16, w: u16, h: u16] (little-endian)
|
||||
* 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,
|
||||
width: number,
|
||||
height: number,
|
||||
): Promise<ImageData | null> {
|
||||
ctx: CanvasRenderingContext2D,
|
||||
): Promise<boolean> {
|
||||
let raw: ArrayBuffer;
|
||||
try {
|
||||
raw = await invoke<ArrayBuffer>("rdp_get_frame", { sessionId });
|
||||
} 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 bytes = new Uint8ClampedArray(raw);
|
||||
const view = new DataView(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;
|
||||
if (bytes.length !== expected) {
|
||||
console.warn(
|
||||
`[useRdp] Frame size mismatch: got ${bytes.length}, expected ${expected}`,
|
||||
);
|
||||
return null;
|
||||
if (pixelData.length !== expected) return false;
|
||||
ctx.putImageData(new ImageData(pixelData, width, height), 0, 0);
|
||||
} else {
|
||||
// Dirty rectangle — apply at offset
|
||||
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 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.
|
||||
function scheduleFrameFetch(): void {
|
||||
if (rafScheduled) return;
|
||||
@ -310,12 +322,9 @@ export function useRdp(): UseRdpReturn {
|
||||
rafScheduled = false;
|
||||
if (fetchPending) return;
|
||||
fetchPending = true;
|
||||
const imageData = await fetchFrame(sessionId, width, height);
|
||||
const rendered = await fetchAndRender(sessionId, width, height, ctx);
|
||||
fetchPending = false;
|
||||
if (imageData && ctx) {
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
if (!connected.value) connected.value = true;
|
||||
}
|
||||
if (rendered && !connected.value) connected.value = true;
|
||||
});
|
||||
}
|
||||
|
||||
@ -363,7 +372,7 @@ export function useRdp(): UseRdpReturn {
|
||||
connected,
|
||||
keyboardGrabbed,
|
||||
clipboardSync,
|
||||
fetchFrame,
|
||||
fetchAndRender,
|
||||
sendMouse,
|
||||
sendKey,
|
||||
sendClipboard,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user