Skip to content

Display Rendering Pipeline

Updating a 320x240 LCD at full frame rate would require transferring 150 KB per frame---far too slow over the NanoVNA’s SPI bus. The firmware uses a cell-based dirty-region system that redraws only the parts of the screen that have changed, achieving responsive updates with minimal bandwidth.

The display is divided into a grid of cells, each containing a portion of the screen:

flowchart TB
  subgraph DISPLAY[320x240 Display]
      direction LR
      subgraph ROW1[Row 0]
          C00[Cell 0,0<br/>32x32]
          C01[Cell 1,0]
          C02[Cell 2,0]
          C03[...]
          C09[Cell 9,0]
      end
      subgraph ROW2[Row 1]
          C10[Cell 0,1]
          C11[...]
          C19[Cell 9,1]
      end
      subgraph ROW7[Row 7]
          C70[Cell 0,7]
          C79[Cell 9,7]
      end
  end

From plot.c:

// Cell dimensions
#define CELLWIDTH 32
#define CELLHEIGHT 32
// Grid size (320x240 display)
// 320 / 32 = 10 columns, 240 / 32 = 7.5 -> 8 rows
#define CELLS_X 10
#define CELLS_Y 8

Instead of a full framebuffer, the firmware uses a single cell buffer that is reused for each cell:

// Cell buffer holds one cell's worth of pixels
static pixel_t cell_buffer[CELLWIDTH * CELLHEIGHT];
// 32 x 32 x 2 bytes = 2048 bytes (vs 153600 for full frame)

This reduces RAM usage from 150 KB to just 2 KB, critical for the memory-constrained STM32F072.

The firmware tracks which cells need redrawing with a bitmask:

// Each bit represents one cell (up to 32 bits = 32 cells)
static uint32_t markmap[CELLS_Y]; // One word per row
// Mark a cell as dirty
static void mark_cell(int x, int y) {
markmap[y] |= (1 << x);
}
// Check if cell needs redraw
static bool cell_dirty(int x, int y) {
return markmap[y] & (1 << x);
}
// Clear after redraw
static void clear_cell(int x, int y) {
markmap[y] &= ~(1 << x);
}

Each frame update follows this sequence:

flowchart LR
  A[Sweep Complete] --> B[Mark Dirty Cells]
  B --> C[For Each Dirty Cell]
  C --> D[Clear Cell Buffer]
  D --> E[Draw Grid Lines]
  E --> F[Draw Traces]
  F --> G[Draw Markers]
  G --> H[DMA to LCD]
  H --> I{More Cells?}
  I --> |Yes| C
  I --> |No| J[Done]

When trace data changes, the firmware marks affected cells:

static void markmap_set_all(void) {
// Mark entire plot area as dirty
for (int y = 0; y < CELLS_Y; y++)
markmap[y] = 0xFFFF; // All cells in row
}
static void markmap_line(int x0, int y0, int x1, int y1) {
// Mark cells along a line (for trace updates)
// Uses Bresenham-like algorithm
int dx = abs(x1 - x0);
int dy = abs(y1 - y0);
// ... mark cells touched by line
}

For each dirty cell, the firmware builds the image in the cell buffer:

static void draw_cell(int x, int y) {
// Clear to background color
for (int i = 0; i < CELLWIDTH * CELLHEIGHT; i++)
cell_buffer[i] = BG_COLOR;
// Draw grid lines (if this cell contains any)
cell_grid(x, y, CELLWIDTH, CELLHEIGHT, GRID_COLOR);
// Draw each enabled trace
for (int t = 0; t < TRACES_MAX; t++) {
if (trace[t].enabled)
cell_draw_trace(x, y, CELLWIDTH, CELLHEIGHT, t);
}
// Draw markers
for (int m = 0; m < MARKERS_MAX; m++) {
if (markers[m].enabled)
cell_draw_marker(x, y, CELLWIDTH, CELLHEIGHT, m);
}
}

The completed cell is sent to the LCD via SPI DMA:

static void lcd_draw_cell(int x, int y) {
// Set LCD window to this cell's coordinates
lcd_set_window(x * CELLWIDTH, y * CELLHEIGHT,
(x + 1) * CELLWIDTH - 1, (y + 1) * CELLHEIGHT - 1);
// DMA transfer cell buffer to LCD
lcd_bulk_send(cell_buffer, CELLWIDTH * CELLHEIGHT);
}

The grid is drawn per-cell, checking which grid lines pass through:

static void cell_grid(int x0, int y0, int w, int h, pixel_t color) {
// Convert cell coordinates to plot coordinates
int x = x0 - OFFSETX;
int y = y0 - OFFSETY;
// Draw vertical grid lines in this cell
for (int gx = 0; gx < WIDTH; gx += GRIDX) {
if (gx >= x && gx < x + w) {
// This grid line passes through the cell
for (int py = 0; py < h; py++)
cell_buffer[py * CELLWIDTH + (gx - x)] = color;
}
}
// Draw horizontal grid lines similarly
// ...
}

Traces are drawn as connected line segments between measurement points:

static void cell_draw_trace(int x0, int y0, int w, int h, int trace_idx) {
trace_t *t = &trace[trace_idx];
pixel_t color = trace_colors[trace_idx];
int prev_x = -1, prev_y = -1;
for (int i = 0; i < sweep_points; i++) {
// Get screen coordinates for this point
int px, py;
trace_get_point(t, i, &px, &py);
// Check if line segment intersects this cell
if (prev_x >= 0) {
if (line_intersects_cell(prev_x, prev_y, px, py, x0, y0, w, h)) {
// Draw clipped line segment in cell buffer
cell_draw_line(prev_x - x0, prev_y - y0, px - x0, py - y0, color);
}
}
prev_x = px;
prev_y = py;
}
}

The Smith chart uses a different grid function that draws circles:

static bool smith_grid(int x, int y) {
// Check if (x,y) lies on any Smith chart circle
// Constant Resistance Circle: R = 1 (center at 0.5, radius 0.5)
int d = circle_inout(x - P_RADIUS/2, y, P_RADIUS/2);
if (d == 0) return true; // On the circle
// Constant Resistance Circle: R = 1/3 (center at 0.25, radius 0.75)
if (circle_inout(x - P_RADIUS/4, y, P_RADIUS*3/4) == 0) return true;
// Reactance arcs (more complex - use arc equations)
// ...
return false; // Not on any grid line
}
static void cell_smith_grid(int x0, int y0, int w, int h, pixel_t color) {
// Render Smith chart grid into cell buffer
for (int y = 0; y < h; y++) {
for (int x = 0; x < w; x++) {
if (smith_grid(x + x0 - P_CENTER_X, y + y0 - P_CENTER_Y))
cell_buffer[y * CELLWIDTH + x] = color;
}
}
}

The LCD driver uses DMA for efficient data transfer:

void lcd_bulk_send(pixel_t *data, size_t count) {
#ifdef __USE_DISPLAY_DMA__
// Configure DMA for SPI transmit
dmaStreamSetMemory0(SPI_DMA_TX, data);
dmaStreamSetTransactionSize(SPI_DMA_TX, count * sizeof(pixel_t));
// Start transfer and wait for completion
dmaStreamEnable(SPI_DMA_TX);
while (dmaStreamGetTransactionSize(SPI_DMA_TX) > 0)
; // Wait for DMA complete
#else
// Fallback: byte-by-byte transfer
for (size_t i = 0; i < count; i++)
spi_send(data[i]);
#endif
}

The firmware supports multiple LCD controllers:

  • 8-bit parallel or SPI interface
  • Used on NanoVNA-H
  • Auto-detected by reading ID register
// LCD driver auto-detection
void lcd_init(void) {
uint32_t id = lcd_read_register(LCD_REG_ID);
if ((id & 0xFFFF) == 0x9341) {
lcd_driver = &ili9341_driver;
} else if ((id & 0xFFFF) == 0x7789) {
lcd_driver = &st7789_driver;
} else if ((id & 0xFFFF) == 0x7796) {
lcd_driver = &st7796s_driver;
}
}

Only dirty cells are redrawn, minimizing SPI traffic:

void plot_redraw(void) {
for (int y = 0; y < CELLS_Y; y++) {
for (int x = 0; x < CELLS_X; x++) {
if (cell_dirty(x, y)) {
draw_cell(x, y);
lcd_draw_cell(x, y);
clear_cell(x, y);
}
}
}
}

During sweep, only cells containing trace data change:

// Mark cells along the new trace segment
int x0 = trace_to_screen_x(prev_point);
int y0 = trace_to_screen_y(prev_point);
int x1 = trace_to_screen_x(new_point);
int y1 = trace_to_screen_y(new_point);
markmap_line(x0, y0, x1, y1);

Markers are drawn on top of traces and require redrawing only the marker cell:

void marker_set_position(int marker, int index) {
// Mark old position dirty
markmap_marker(markers[marker].index);
// Update position
markers[marker].index = index;
// Mark new position dirty
markmap_marker(index);
}

Typical refresh performance:

ScenarioCells UpdatedRefresh Rate
Full redraw80 cells~2-3 FPS
Trace update10-20 cells~10-15 FPS
Marker move2-4 cells~30+ FPS
Menu overlay5-10 cells~15-20 FPS
ComponentPurposeMemory
Cell bufferRender one cell at a time2 KB
MarkmapTrack dirty cells32 bytes
LCD driverSPI/DMA interfaceMinimal

The cell-based dirty-region system enables responsive graphics on a memory-limited microcontroller by:

  1. Dividing the display into independent cells
  2. Tracking which cells have changed
  3. Rendering only dirty cells to a small buffer
  4. Using DMA for efficient transfer

This architecture makes the NanoVNA’s touch interface feel snappy despite the modest hardware.