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)
|
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.
|
||||||
|
|||||||
@ -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(()); }
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
const expected = width * height * 4;
|
if (rx === 0 && ry === 0 && rw === 0 && rh === 0) {
|
||||||
if (bytes.length !== expected) {
|
// Full frame
|
||||||
console.warn(
|
const expected = width * height * 4;
|
||||||
`[useRdp] Frame size mismatch: got ${bytes.length}, expected ${expected}`,
|
if (pixelData.length !== expected) return false;
|
||||||
);
|
ctx.putImageData(new ImageData(pixelData, width, height), 0, 0);
|
||||||
return null;
|
} 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 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,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user