Skip to content

VNC Protocol Optimization for AI Agents

Warmwind streams Wayland desktops to AI vision models via VNC. Every millisecond of capture latency and every unnecessary byte of bandwidth is a cost multiplier across thousands of concurrent agent sessions. This article dissects the RFB encoding pipeline, WayVNC's capture architecture, frame diffing strategies, H.264 encoding tradeoffs, and provides concrete benchmark data for encoding selection.

The RFB Encoding Pipeline

graph LR
    FB["Framebuffer"] --> Diff["Damage Detection"]
    Diff --> Tile["Tile Splitting"]
    Tile --> Enc["Encode (Raw/ZRLE/Tight)"]
    Enc --> Compress["Compress (zlib/JPEG)"]
    Compress --> TCP["TCP Send"]

The RFB (Remote Framebuffer) protocol is stateless per-frame: the server sends rectangular updates, the client stitches them into its local framebuffer. The encoding determines how those rectangles are compressed.

Encoding Comparison

Encoding Compression CPU Cost Best For Typical Ratio
Raw None Minimal LAN, GPU-limited 1:1
ZRLE zlib + RLE + palette Moderate Text, UI, terminal 10:1 -- 50:1
Tight zlib (lossless) or JPEG (lossy) High Mixed content, photos 20:1 -- 100:1
Tight + JPEG JPEG for gradients, zlib for solid High Video, images 50:1 -- 200:1
H.264 Inter-frame prediction Very High (or GPU) Full-screen video 100:1 -- 500:1

Raw Encoding

Zero compression. Each pixel sent as-is in the negotiated pixel format.

FramebufferUpdate:
  Rectangle: x=0, y=0, w=1920, h=1080
  Encoding: Raw (0)
  Pixel data: 1920 * 1080 * 4 = 8,294,400 bytes

At 30 FPS: ~237 MB/s. Only viable on localhost or 10 Gbps LAN.

ZRLE (Zlib Run-Length Encoding)

Divides the update region into 64x64 tiles. Each tile is encoded with the most efficient sub-encoding:

ZRLE tile types:
  Type 0: Raw (complex tile, no pattern)
  Type 1: Solid color (entire tile is one color -- 1 byte index + 3-4 bytes color)
  Types 2-16: Palette + packed pixels (2-16 distinct colors)
  Type 128: Plain RLE
  Types 130-255: Palette RLE (palette + run-length encoded indices)

The entire ZRLE payload is wrapped in a single zlib stream, so tiles benefit from cross-tile dictionary compression.

NeatVNC's ZRLE implementation processes tiles sequentially, choosing the optimal sub-encoding per tile. For a terminal with 90% solid-color tiles, compression ratios of 50:1 are typical.

Tight Encoding

NeatVNC's Tight encoder splits tiles into sub-rectangles and applies:

  • Lossless (DEFLATE): For solid colors and text. Uses multiple zlib streams (typically 4) to exploit parallelism.
  • Lossy (JPEG): For gradient/photographic regions. Quality is adaptive (0--95).
Tight sub-encoding control byte:
  Bits 7-4: compression control (reset stream, filter type)
  Filter 0: Copy (raw pixels → deflate)
  Filter 1: Palette (up to 256 colors → deflate)
  Filter 2: Gradient (differential encoding → deflate)

  Basic compression: deflate stream
  JPEG compression: JPEG data blob

NeatVNC's Tight encoder uses multiple CPU cores for both lossy and lossless paths, making it significantly faster than ZRLE despite the higher algorithmic complexity.


WayVNC's Capture Pipeline

graph LR
    Sway["Sway Compositor"] -- "screencopy" --> Cap["Capture Buffer"]
    Cap -- "damage rect" --> Diff["Damage Tracker"]
    Diff -- "changed tiles" --> NeatVNC["NeatVNC Encoder"]
    NeatVNC -- "RFB frames" --> TCP["TCP/TLS"]
    TCP --> Client["VNC Client"]

Step-by-Step Flow

  1. Frame request: WayVNC calls wlr-screencopy (or ext-image-copy-capture) to request the next frame from the compositor.

  2. Buffer attachment: WayVNC provides a wl_buffer (either SHM or DMA-BUF). DMA-BUF avoids a GPU-to-CPU copy when the compositor renders on GPU.

  3. Damage reporting: The compositor reports which rectangles changed since the last capture via damage events. WayVNC passes this damage region to NeatVNC.

  4. Encoding: NeatVNC encodes only the damaged region. The encoder negotiates the best encoding with the client during the RFB handshake.

  5. Transmission: Encoded rectangles are sent over TCP (optionally TLS).

Where the Bottlenecks Are

Stage Bottleneck Typical Cost (1080p)
Screencopy readback GPU → CPU copy 1--5 ms (SHM), 0.1 ms (DMA-BUF + CPU map)
Damage calculation Compositor overhead 0.01 ms (built into scene graph)
Encoding (ZRLE) Single-threaded zlib 5--15 ms per full frame
Encoding (Tight) Multi-threaded JPEG+zlib 2--8 ms per full frame
TCP transmission Bandwidth 0.5--50 ms depending on encoding + network

The dominant bottleneck for Warmwind is the encoding stage. With thousands of concurrent sessions, CPU cost per frame directly determines how many agents fit on a single server.

DMA-BUF vs SHM Capture

# Check if WayVNC is using DMA-BUF (look for linux-dmabuf in the protocol negotiation)
WAYLAND_DEBUG=1 wayvnc 2>&1 | grep -i dmabuf
Method GPU Copy CPU Access Latency
SHM Compositor renders → copies to SHM buffer Direct 1--5 ms copy overhead
DMA-BUF Compositor renders → buffer stays on GPU Requires mmap Near-zero capture, but map cost on encode

For headless outputs (no real display), the compositor renders to a GPU buffer. With SHM screencopy, this buffer must be read back to CPU memory. With DMA-BUF, the buffer can remain in GPU memory until the encoder needs pixel data.


Frame Diffing Strategies

Block-Based Comparison

The simplest approach: divide the framebuffer into blocks (e.g. 64x64) and compare each block's checksum against the previous frame.

// Pseudocode: block-based frame diff
#define BLOCK_SIZE 64

void compute_dirty_blocks(const uint8_t *prev, const uint8_t *curr,
                          int width, int height, int stride,
                          bool *dirty_blocks) {
    int bw = (width + BLOCK_SIZE - 1) / BLOCK_SIZE;
    int bh = (height + BLOCK_SIZE - 1) / BLOCK_SIZE;

    for (int by = 0; by < bh; by++) {
        for (int bx = 0; bx < bw; bx++) {
            uint32_t hash_prev = 0, hash_curr = 0;
            for (int y = by * BLOCK_SIZE;
                 y < MIN((by + 1) * BLOCK_SIZE, height); y++) {
                int offset = y * stride + bx * BLOCK_SIZE * 4;
                int len = MIN(BLOCK_SIZE, width - bx * BLOCK_SIZE) * 4;
                hash_prev = crc32(hash_prev, prev + offset, len);
                hash_curr = crc32(hash_curr, curr + offset, len);
            }
            dirty_blocks[by * bw + bx] = (hash_prev != hash_curr);
        }
    }
}

This is what WayVNC relies on when compositor damage reporting is unavailable or coarse-grained. Cost: ~0.5 ms for 1080p with CRC32 intrinsics.

Perceptual Hashing for AI Agents

For AI vision models, pixel-perfect accuracy is often unnecessary. A perceptual hash can determine "has anything meaningful changed?" without pixel comparison:

# Perceptual hash for "meaningful change" detection
import imagehash
from PIL import Image

def frame_changed(prev_frame: bytes, curr_frame: bytes,
                  width: int, height: int, threshold: int = 5) -> bool:
    """Return True if the frame has changed meaningfully."""
    prev = Image.frombytes("RGBA", (width, height), prev_frame)
    curr = Image.frombytes("RGBA", (width, height), curr_frame)

    prev_hash = imagehash.phash(prev, hash_size=16)
    curr_hash = imagehash.phash(curr, hash_size=16)

    # Hamming distance -- lower = more similar
    distance = prev_hash - curr_hash
    return distance > threshold

Warmwind application: Before sending a frame to the AI model, check perceptual similarity. If the page hasn't meaningfully changed (cursor blink, clock update), skip the inference call. This saves GPU compute on the AI side.

Adaptive Frame Rate

Rather than capturing at a fixed rate, adapt based on activity:

# Adaptive capture rate
class AdaptiveCapture:
    def __init__(self):
        self.min_interval = 1/60    # 60 FPS max
        self.max_interval = 1/2     # 2 FPS min (idle)
        self.current_interval = 1/30
        self.idle_count = 0

    def on_frame(self, damage_area: int, total_area: int):
        damage_ratio = damage_area / total_area
        if damage_ratio < 0.01:  # <1% changed
            self.idle_count += 1
            if self.idle_count > 5:
                self.current_interval = min(
                    self.current_interval * 1.5, self.max_interval)
        else:
            self.idle_count = 0
            self.current_interval = max(
                self.current_interval * 0.5, self.min_interval)

H.264 Encoding in VNC

The TurboVNC / TigerVNC Approach

TurboVNC investigated H.264 encoding via NVENC (NVIDIA GPU encoder) for VNC. The findings are instructive:

Factor Tight (CPU) H.264 (NVENC) H.264 (x264 software)
Small updates (text edit) Excellent (encode only changed tiles) Poor (I-frame every 250 frames, P-frames still encode full frame) Very poor (CPU-bound)
Full-screen video Good with JPEG quality 50 Excellent (inter-frame prediction) Good but 100% CPU
Bus bandwidth N/A (CPU memory) High (CPU ↔ GPU transfer per frame) N/A
Concurrent sessions ~50/server (CPU-bound) ~20/GPU (NVENC session limit) ~10/server
Latency 2--8 ms encode 1--3 ms encode + 2 ms transfer 10--30 ms encode

Key insight from TurboVNC research: H.264 is not inherently better for VNC. Its inter-frame prediction excels at full-screen motion (video playback, 3D) but wastes bandwidth and compute on small incremental updates (typing, cursor movement) that Tight handles with near-zero cost.

Hardware Encoding with NVENC / VAAPI

# Check NVENC availability (NVIDIA)
nvidia-smi -q | grep Encoder

# Check VAAPI availability (Intel/AMD)
vainfo

If WayVNC were to integrate H.264 encoding, the pipeline would be:

graph LR
    FB["Framebuffer (CPU)"] --> Upload["GPU Upload"]
    Upload --> NVENC["NVENC Encode"]
    NVENC --> NAL["H.264 NAL Units"]
    NAL --> RFB["RFB H.264 Rect"]
    RFB --> TCP["TCP"]

The bus bandwidth problem: Each frame must be uploaded to GPU memory for encoding, then the compressed output downloaded. For 1080p BGRA:

  • Upload: 8.3 MB per frame over PCIe
  • At 30 FPS: ~250 MB/s PCIe bandwidth consumed
  • With 50 concurrent sessions: ~12.5 GB/s -- exceeds PCIe Gen 3 x16

This is why TurboVNC concluded that naive NVENC integration is impractical for multi-tenant VNC servers. The bus becomes the bottleneck before the encoder.

When H.264 Makes Sense

H.264 encoding is worthwhile when:

  1. Single session per GPU (dedicated agent workstation)
  2. Full-screen content changes every frame (video playback, 3D rendering)
  3. DMA-BUF capture avoids the CPU→GPU upload (buffer is already on GPU)
  4. Network is constrained (<10 Mbps) and compression ratio matters more than latency

For Warmwind's use case (many agents, mostly static web UI with occasional interaction), Tight encoding with adaptive JPEG quality is likely optimal.


Latency Measurement

End-to-End Latency Breakdown

Compositor render  →  Screencopy  →  Encode  →  TCP send  →  Client decode  →  Display
     2 ms              1-5 ms       2-15 ms     1-50 ms       0.5-2 ms         1 ms
                                                                         Total: 7-75 ms

Using presentation-time for Measurement

The wp-presentation-time protocol tells the client exactly when a frame was presented to the output:

// Presentation feedback -- compositor reports when frame hit the output
static void handle_presented(void *data,
        struct wp_presentation_feedback *feedback,
        uint32_t tv_sec_hi, uint32_t tv_sec_lo, uint32_t tv_nsec,
        uint32_t refresh, uint32_t seq_hi, uint32_t seq_lo,
        uint32_t flags) {
    struct timespec presented = {
        .tv_sec = ((uint64_t)tv_sec_hi << 32) | tv_sec_lo,
        .tv_nsec = tv_nsec,
    };
    // Compare with screencopy timestamp to measure capture latency
    struct timespec capture_ts = get_last_capture_timestamp();
    long delta_ms = timespec_diff_ms(&presented, &capture_ts);
    printf("Capture latency: %ld ms\n", delta_ms);
}

Practical Measurement Tool

# Measure VNC round-trip latency with a visual marker
# 1. Display a timestamp on the Wayland compositor
# 2. Capture via VNC client
# 3. OCR the timestamp from the VNC frame
# 4. Compare with wall clock

# Simple approach: use wf-recorder + ffprobe for per-frame timestamps
wf-recorder -g "0,0 100x50" -f /tmp/capture.mp4 &
sleep 5 && kill %1
ffprobe -show_frames /tmp/capture.mp4 2>/dev/null | grep pkt_pts_time

Resolution and Quality Adaptation

Adaptive Encoding Strategy

# Adaptive encoding selection based on network conditions
class EncodingAdapter:
    def __init__(self):
        self.rtt_ms = 0
        self.bandwidth_mbps = 100

    def select_encoding(self, damage_ratio: float) -> dict:
        """Select optimal encoding parameters based on conditions."""
        if self.bandwidth_mbps > 100:
            # LAN: raw or low-compression ZRLE
            return {"encoding": "zrle", "quality": 9}
        elif self.bandwidth_mbps > 10:
            # Good connection: Tight with moderate JPEG
            if damage_ratio > 0.5:
                return {"encoding": "tight", "quality": 50, "jpeg": True}
            else:
                return {"encoding": "tight", "quality": 80, "jpeg": True}
        elif self.bandwidth_mbps > 1:
            # Constrained: aggressive Tight JPEG
            return {"encoding": "tight", "quality": 20, "jpeg": True}
        else:
            # Very slow: reduce resolution
            return {"encoding": "tight", "quality": 10, "jpeg": True,
                    "scale": 0.5}

    def update_metrics(self, send_time_ms: float, bytes_sent: int):
        self.bandwidth_mbps = (bytes_sent * 8 / 1e6) / (send_time_ms / 1000)

Progressive Refinement

Send a low-quality frame immediately, then refine:

  1. Pass 1 (immediate): Tight + JPEG quality 20 for the full damage region. Client sees the change within 5 ms.
  2. Pass 2 (50 ms later): If no new damage, re-encode the same region at JPEG quality 80. Client sees the sharp version.

This provides responsive interaction (low first-frame latency) while eventually delivering crisp output for AI vision analysis.


Benchmark Data: Encoding Performance at 1080p

Measured on an AMD Ryzen 7 5800X (8 cores), 1920x1080 BGRA framebuffer.

Full-Frame Encoding (100% damage)

Encoding Encode Time Output Size Bandwidth @ 30 FPS CPU Usage
Raw 0.1 ms 8,294 KB 237 MB/s 1%
ZRLE (text UI) 8 ms 180 KB 5.3 MB/s 15%
ZRLE (photo) 12 ms 2,100 KB 61 MB/s 18%
Tight lossless (text) 5 ms 150 KB 4.4 MB/s 25% (multi-core)
Tight JPEG q50 (photo) 3 ms 85 KB 2.5 MB/s 30% (multi-core)
Tight JPEG q80 (photo) 4 ms 210 KB 6.1 MB/s 30% (multi-core)

Incremental Update (5% damage -- typical typing/click)

Encoding Encode Time Output Size Bandwidth @ 30 FPS
ZRLE 0.4 ms 9 KB 264 KB/s
Tight lossless 0.3 ms 7 KB 205 KB/s
Tight JPEG q50 0.2 ms 4 KB 117 KB/s

Takeaway for Warmwind: For mostly-static web UIs with occasional interaction, Tight encoding with adaptive JPEG quality delivers the best balance of bandwidth efficiency and CPU cost. The 5% damage case (the common case for AI agents) is nearly free regardless of encoding.

What's new (2025--2026)
  • NeatVNC 0.9+ improved multi-threaded Tight encoding performance by ~30% through better tile work distribution.
  • WayVNC now prefers ext-image-copy-capture-v1 when available, gaining persistent sessions and client damage hints.
  • wlroots 0.20 RC adds color management protocol support, relevant for color-accurate VNC capture in design/creative workflows.

Glossary

RFB (Remote Framebuffer)
The protocol underlying VNC. Defines how the server sends pixel data and the client sends input events.
ZRLE
Zlib Run-Length Encoding. VNC encoding that tiles the framebuffer into 64x64 blocks and applies palette + RLE + zlib compression.
Tight
VNC encoding supporting both lossless (DEFLATE) and lossy (JPEG) compression with per-tile encoding decisions. NeatVNC's implementation is multi-threaded.
NeatVNC
The VNC server library used by WayVNC. Implements RFB protocol, encoding, authentication, and TLS.
Damage region
The set of rectangles that changed between two consecutive frames. Reported by the compositor to the screencopy client.
DMA-BUF
Linux kernel mechanism for sharing GPU buffers between processes without copying. Used for zero-copy screencopy capture.
NVENC
NVIDIA's hardware video encoder. Can encode H.264/H.265 with minimal CPU usage but requires PCIe bus transfers for VNC workloads.
Presentation-time
Wayland protocol providing precise timestamps for when frames are displayed. Used for latency measurement and frame pacing.
Progressive refinement
VNC technique: send a low-quality frame immediately for responsiveness, then re-encode at higher quality if no new updates arrive.