Rendering Pipeline
How the virtual tree becomes an image on Stream Deck hardware.
VNode Tree
After the React reconciler commits, the host nodes form a virtual tree:
interface VNode {
type: string; // 'div', 'span', 'img', 'svg', etc.
props: Record<string, unknown>;
children: VNode[];
text?: string; // For text instances
/** @internal Back-pointer to parent VNode for dirty propagation. */
_parent?: VNode | VContainer;
/** @internal True when this node or a descendant has been mutated since last flush. */
_dirty?: boolean;
/** @internal Cached Merkle hash for this subtree. */
_hash?: number;
/** @internal True when _hash is still valid (no mutations since last hash). */
_hashValid?: boolean;
}When a VNode is mutated, the _parent back-pointer enables O(depth) dirty propagation — markDirty walks up from the changed node, setting _dirty on each ancestor until it reaches the container root. This is the foundation of Phase 1 in the skip hierarchy.
Direct VNode-to-Takumi Bypass
The pipeline converts VNodes directly to Takumi's node format in a single tree walk, bypassing two intermediate steps that the standard path would require:
Standard path (eliminated):
VNode → vnodeToElement() → React element → fromJsx() → Takumi node
(2 full tree walks + 2× node allocations per render)
Bypass path (used):
VNode → vnodeToTakumiNode() → Takumi node
(1 tree walk, saves ~1–5ms per frame)The mapping handles four VNode types:
| VNode type | Takumi node type | Notes |
|---|---|---|
#text | TextNode | { type: "text", text } |
img | ImageNode | { type: "image", src } |
svg | ImageNode | SVG subtree serialized to markup string |
* (all others) | ContainerNode | { type: "container", children } |
The className prop is mapped to Takumi's tw field for built-in Tailwind CSS resolution.
4-Phase Skip Hierarchy
Every render attempt passes through a multi-tier skip hierarchy designed to avoid redundant work. Each phase has progressively higher cost; earlier phases act as fast-path exits.
flush()
│
▼
Phase 1: Dirty-flag check (O(1))
│ If no VNode was mutated since last flush, skip entirely.
│
▼
Phase 2: Merkle-tree hash → Image cache lookup
│ Compute structural hash of the VNode tree.
│ If hash+config key is in the LRU image cache, return cached result.
│
▼
Phase 3: Takumi render (main thread or worker)
│ Convert VNode tree → Takumi nodes (direct bypass).
│ Pass to native Takumi renderer for rasterization.
│
▼
Phase 4: xxHash output dedup
│ Hash the raw raster buffer (xxHash-wasm, FNV-1a fallback).
│ If identical to the previous frame, skip encoding and hardware push.
│
▼
Encode → base64 data URI → store in cache → push to hardwarePhase 1: Dirty-Flag Check
Cost: O(1) — a single boolean check on the container.
When a VNode is mutated, dirty flags propagate up through _parent back-pointers to the container root. If the container's dirty flag is false, no node was modified since the last flush, and the entire render is skipped.
Phase 2: Merkle Hash + Image Cache
Cost: O(depth) for single-node mutations, O(n) worst case.
A Merkle-style hash is computed over the VNode tree. Unchanged subtrees reuse their cached _hash values, so a single-node mutation only rehashes the path from the changed node to the root. The resulting tree hash is combined with render parameters (width, height, device pixel ratio, image format) to form a cache key.
If the key is found in the byte-bounded LRU image cache, the cached data URI (or raw buffer for TouchStrip) is returned immediately — skipping the entire Takumi render.
Phase 3: Takumi Render
Cost: 5–30ms depending on tree complexity.
The VNode tree is converted to Takumi nodes via the direct bypass path and passed to the native Rust renderer for rasterization. This can happen on the main thread or be offloaded to a worker thread (see Worker Thread Pool).
Phase 4: xxHash Output Dedup
Cost: O(n) over the output buffer.
After rendering, the raw pixel buffer is hashed via xxHash-wasm — a WASM-compiled xxHash that processes the entire buffer in native code, significantly faster than a JS byte loop even for 320 KB TouchStrip frames. If the hash matches the previous frame's hash (lastSvgHash), the component re-rendered but produced no visual change — encoding and the hardware push are skipped.
If the WASM module hasn't compiled yet (brief window at startup, ~1ms), the pipeline falls back to FNV-1a with strided sampling (every 16th byte) until xxHash is ready.
In debug mode, consecutive identical renders are counted and a warning is logged after 3 duplicates, helping identify components that re-render without visual effect.
Two Entry Points
The pipeline provides two render functions for different surfaces:
| Entry point | Surface | Returns | Cache |
|---|---|---|---|
renderToDataUri | Keys, dials | Base64 data URI string | Image cache (16 MB default) |
renderToRaw | TouchStrip | Raw RGBA Buffer | TouchStrip cache (8 MB default) |
Both follow the same 4-phase skip hierarchy. The key difference is that renderToRaw skips the base64 encoding step — the caller (TouchStripRoot) crops per-encoder segments from the raw buffer and encodes each independently.
Render Configuration
The full render configuration is built internally from your createPlugin() options:
interface RenderConfig {
renderer: Renderer;
imageFormat: OutputFormat; // "png" | "webp"
caching: boolean;
devicePixelRatio: number;
debug: boolean;
imageCacheMaxBytes: number; // Default: 16 MB
touchStripCacheMaxBytes: number; // Default: 8 MB
renderPool: RenderPool | null;
onRender?: (container: VContainer, dataUri: string) => void;
onProfile?: (profile: RenderProfile) => void;
}Most of these map directly to PluginConfig options:
| PluginConfig option | RenderConfig field | Default |
|---|---|---|
imageFormat | imageFormat | "png" |
caching | caching | true |
devicePixelRatio | devicePixelRatio | 1 |
debug | debug | NODE_ENV !== 'production' |
imageCacheMaxBytes | imageCacheMaxBytes | 16777216 (16 MB) |
touchStripCacheMaxBytes | touchStripCacheMaxBytes | 8388608 (8 MB) |
useWorker | renderPool | true (creates a RenderPool) |
Image Cache
The image cache is a byte-bounded LRU cache shared across all React roots. Unlike a count-bounded cache, byte-bounding handles the wide size variance of Stream Deck images correctly — a 72×72 key PNG is ~2KB while an 800×100 raw RGBA TouchStrip buffer is 320KB.
Two separate caches exist:
- Image cache (
ImageCache<string>) — stores base64 data URIs for key/dial renders. Default budget: 16 MB. - TouchStrip cache (
ImageCache<Buffer>) — stores raw RGBA buffers. Default budget: 8 MB.
Both use a doubly-linked list for O(1) LRU eviction and a Map for O(1) lookups. When the total byte size exceeds the budget, the least-recently-used entries are evicted from the tail.
Cache sizes are configurable via imageCacheMaxBytes and touchStripCacheMaxBytes in createPlugin(). Set to 0 to disable.
Buffer Pool
At 30fps TouchStrip rendering, each frame allocates ~320KB of raw RGBA buffers (800×100×4 bytes). Without pooling, V8's garbage collector must reclaim ~10MB/s of short-lived buffers, causing periodic frame drops visible as stutter.
The BufferPool recycles buffers by exact byte size:
acquire(size)— returns a zeroed buffer from the matching bucket, or allocates a new onerelease(buf)— returns the buffer to its size bucket for reuse
Each size bucket is capped at 8 buffers to prevent unbounded memory growth during brief spikes of concurrent renders. The pool is shared as a singleton across all roots.
Worker Thread Pool
The RenderPool offloads Takumi rasterization to a worker thread so the main thread stays responsive for SDK event handling and React reconciliation.
Why: Takumi's native rasterization (Rust via NAPI) blocks the calling thread for 5–30ms per frame. During 30fps TouchStrip animation, this leaves almost no time for processing keyDown or dialRotate events on the main thread.
The worker receives serialized VNode trees (stripped of back-pointers and function props that can't cross the structured-clone boundary), converts them to Takumi nodes, and returns the raster buffer via zero-copy ArrayBuffer transfer.
If the worker fails to initialize (e.g., native addon can't load in worker context), the pipeline transparently falls back to main-thread rendering. Controlled via useWorker in PluginConfig (default: true).
Adaptive Debounce
Rather than a fixed debounce for all renders, the pipeline detects rendering patterns and chooses the optimal delay:
| Mode | Condition | Debounce | Why |
|---|---|---|---|
| Animating | >2 renders in a 100ms window | 0ms | Spring/tween animations need every frame delivered |
| Interactive | User input within 500ms | min(configured, 16ms) | Key press and dial rotation need fast visual feedback |
| Idle | No recent activity | configured (default 16ms) | Settings changes, initial render — batch updates |
Multiple state updates within a single event handler are still batched by React's automatic batching. The adaptive debounce is an additional layer on top, applied after the reconciler commits.
The debounce mode also drives render priority — animating roots get priority 0 (highest), ensuring they flush to hardware before idle roots.
Render Metrics
The RenderMetrics collector tracks pipeline statistics for performance monitoring:
interface RenderMetrics {
flushCount: number; // Total flush() calls
renderCount: number; // Flushes that reached Takumi
cacheHitCount: number; // Phase 2 skips (cache hit)
dirtySkipCount: number; // Phase 1 skips (clean tree)
hashDedupCount: number; // Phase 4 skips (xxHash output dedup)
avgRenderMs: number; // Average Takumi render time
peakRenderMs: number; // Worst-case render time
imageCacheBytes: number; // Image cache memory usage
touchStripCacheBytes: number; // TouchStrip cache memory usage
}In debug mode, metrics are logged to the console every 10 seconds with a summary including the skip rate — the percentage of flush attempts that were short-circuited by the caching tiers. A well-optimized plugin typically sees 60–90% skip rates.
Cumulative counters (never reset) are also maintained for the DevTools Performance panel, which reads them via snapshot().
Render Profiling
Each individual render can be profiled via the onProfile callback in RenderConfig:
interface RenderProfile {
/** Time to convert VNode tree to Takumi node tree (ms). */
vnodeConversionMs: number;
takumiRenderMs: number;
hashMs: number;
base64Ms: number;
totalMs: number;
skipped: boolean;
cacheHit: boolean;
treeDepth: number;
nodeCount: number;
cacheStats: CacheStats | null;
}This data flows through the devtools bridge to the Performance panel via SSE, providing per-render timing breakdowns for diagnosing bottlenecks.
Output Format
The renderer writes PNG by default and can be configured to output WebP via imageFormat: "webp" in createPlugin().
Dimensions (width/height) are determined automatically from the device type and controller. See Device Sizes for the full table.