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.
Display Architecture
Section titled “Display Architecture”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 8The Cell Buffer
Section titled “The Cell Buffer”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 pixelsstatic 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.
Dirty Region Tracking
Section titled “Dirty Region Tracking”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 dirtystatic void mark_cell(int x, int y) { markmap[y] |= (1 << x);}
// Check if cell needs redrawstatic bool cell_dirty(int x, int y) { return markmap[y] & (1 << x);}
// Clear after redrawstatic void clear_cell(int x, int y) { markmap[y] &= ~(1 << x);}Rendering Pipeline
Section titled “Rendering Pipeline”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] Step 1: Mark Dirty Cells
Section titled “Step 1: Mark Dirty Cells”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}Step 2: Render Each Cell
Section titled “Step 2: Render Each Cell”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); }}Step 3: Transfer to LCD
Section titled “Step 3: Transfer to LCD”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);}Grid Drawing
Section titled “Grid Drawing”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 // ...}Trace Drawing
Section titled “Trace Drawing”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; }}Smith Chart Rendering
Section titled “Smith Chart Rendering”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; } }}DMA Transfer
Section titled “DMA Transfer”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}Display Types
Section titled “Display Types”The firmware supports multiple LCD controllers:
- 8-bit parallel or SPI interface
- Used on NanoVNA-H
- Auto-detected by reading ID register
- SPI interface only
- Alternative to ILI9341
- Similar command set
- Used on NanoVNA-H4
- Larger display requires more cells
- Higher SPI clock for adequate refresh
// LCD driver auto-detectionvoid 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; }}Performance Optimization
Section titled “Performance Optimization”Partial Updates
Section titled “Partial Updates”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); } } }}Trace-Only Updates
Section titled “Trace-Only Updates”During sweep, only cells containing trace data change:
// Mark cells along the new trace segmentint 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);Marker Rendering
Section titled “Marker Rendering”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);}Update Rates
Section titled “Update Rates”Typical refresh performance:
| Scenario | Cells Updated | Refresh Rate |
|---|---|---|
| Full redraw | 80 cells | ~2-3 FPS |
| Trace update | 10-20 cells | ~10-15 FPS |
| Marker move | 2-4 cells | ~30+ FPS |
| Menu overlay | 5-10 cells | ~15-20 FPS |
Summary
Section titled “Summary”| Component | Purpose | Memory |
|---|---|---|
| Cell buffer | Render one cell at a time | 2 KB |
| Markmap | Track dirty cells | 32 bytes |
| LCD driver | SPI/DMA interface | Minimal |
The cell-based dirty-region system enables responsive graphics on a memory-limited microcontroller by:
- Dividing the display into independent cells
- Tracking which cells have changed
- Rendering only dirty cells to a small buffer
- Using DMA for efficient transfer
This architecture makes the NanoVNA’s touch interface feel snappy despite the modest hardware.