Skip to content

Rendering

codingncaffeine edited this page Sep 8, 2026 · 2 revisions

Rendering

Everything drawn for this panel is a 640×48 SkiaSharp surface, copied to the device as BGRA with an ordered dither applied on the way out.

The pipeline

SKCanvas  →  SKBitmap (Bgra8888)  →  dither  →  121 HID writes

There is no channel swap. SkiaSharp's Bgra8888 layout on a little-endian machine is byte-for-byte the panel's wire order, so the surface's pixel span is the upload buffer.

Frame budget

At 24 fps the budget is 41.7 ms; at 30 fps it is 33.3 ms. Measured per-frame cost on real hardware is 15.1–18.4 ms, essentially all of it the 121 HID writes. That leaves roughly half the budget for drawing, which is a lot — all 32 visualizer modes hold 30 fps exactly, including the ones that touch every pixel every frame.

Two rules keep it that way.

Never allocate a native object per frame

SkiaSharp objects wrap native memory the GC cannot see, so an undisposed one leaks with zero GC pressure — no memory warning, no collection, nothing to suggest a problem until the process is killed. An SKPathBuilder created per frame in one chart routine leaked about 192 objects a second and eventually got the editor OOM-killed.

Paints, shaders, bitmaps and point arrays are all allocated once and reused. Note that Detach() does not dispose the builder.

Never touch pixels one at a time

SKBitmap.SetPixel is a managed→native call per pixel. At 640×48 that is 30720 of them per frame, and it measurably missed the frame target — 27.7 fps against 30 while every other mode made 30.0 exactly.

Full-pixel effects compose into a byte[] and push it with a single Marshal.Copy. Same output, back to 30.0 fps.

Drawing for 48 pixels of height

The whole display is 48 px tall. For a meter with a 60 dB range that is 1.25 dB per pixel, which changes what is worth drawing:

  • Peak caps carry more information than bar height does. A transient that moves a bar by two pixels moves a cap to a position that stays put and can be read.
  • Auto-ranging beats fixed scale, which reverses the obvious choice. A fixed 20–95 °C scale renders a drive moving 36.8→37.1 °C as one fifth of a pixel, so every chart is a flat line. Auto-ranged, the same four degrees travel thirteen pixels. Magnitude is not lost, because the reading and its min/max are printed next to the chart.
  • A floor on the auto-ranged span is required, or a perfectly still sensor has its last digit of noise stretched to full height and looks like a seismograph.
  • Effects ported from full-screen visualizers carry constants tuned for hundreds of rows. The classic fire routine subtracts 1–2 per row, which is right over 300 rows and leaves a 255 seed still at ~192 after 48 — every pixel lit, no flame tip, a formless wash. Derive falloff from the height.

Colour

Two constraints stack: the panel is 18-bit, and its green primary is yellow-shifted (see The Panel).

  • Gradients are dithered with an 8×8 ordered Bayer matrix, scaled to exactly the two bits the panel discards. Ordered, not error-diffused, so a static display does not shimmer.
  • Segmented and near-binary designs avoid the quantisation entirely, which is a real reason to offer them rather than a nostalgic one.
  • Every palette is provisional until it has been seen on the glass.

Layout

The strip is divided among cells — sensor readouts, touch buttons and visualizers — which all share one weight budget. A screen with four readouts and one button gives the button a fifth of the strip, not half.

Lead and trail weights are part of the same budget, so a screen using fewer cells leaves the remainder as background rather than stretching one cell edge to edge. A single cell has no neighbour to drag against, which is the practical reason the gaps exist at all.

One inherited wart, documented because it is visible: cells are laid out grouped by type, so a readout cannot sit between two buttons. Ordering is fixed by type rather than by the configuration.

Clone this wiki locally