Performance Guide
Optimizing rendering, interaction, and memory usage in large diagrams
Beep.Skia is designed to handle diagrams ranging from small flowcharts (10–50 components) to large enterprise architecture maps (500–5,000+ components). This guide covers the optimization strategies built into the rendering pipeline and the techniques you can apply in custom components to maintain 60 FPS even at scale.
Table of Contents
Rendering Optimization
Dirty Region Tracking
The DrawingManager maintains a dirty region system that tracks which portions of the canvas require redrawing. Instead of re-rendering the entire canvas on every Invalidate() call, only the union of dirty rectangles is redrawn:
Dirty Region Mechanism
// Component-level invalidation: marks only this component's bounds as dirty
public override void Invalidate()
{
_isDirty = true;
DrawingManager?.AddDirtyRegion(Bounds);
}
// DrawingManager aggregates all dirty regions
public void AddDirtyRegion(SKRect region)
{
if (_dirtyRegion.IsEmpty)
_dirtyRegion = region;
else
_dirtyRegion = SKRect.Union(_dirtyRegion, region);
}
// During render, only the dirty region is redrawn
public void Render(SKCanvas canvas)
{
if (_dirtyRegion.IsEmpty) return;
canvas.Save();
canvas.ClipRect(_dirtyRegion);
foreach (var component in _components)
{
if (component.Bounds.IntersectsWith(_dirtyRegion))
component.DrawContent(canvas);
}
canvas.Restore();
_dirtyRegion = SKRect.Empty;
}
Skip Off-Screen Components
Components whose bounds fall completely outside the viewport (considering pan and zoom transforms) are skipped during rendering. The RenderingHelper performs a world-space-to-screen-space bounds check before calling DrawContent():
Viewport Culling
public void RenderVisibleComponents(SKCanvas canvas, SKRect viewportWorld)
{
foreach (var component in _components)
{
// Expand viewport slightly to avoid pop-in at edges
var expandedViewport = viewportWorld;
expandedViewport.Inflate(100, 100);
if (!component.Bounds.IntersectsWith(expandedViewport))
continue; // Skip off-screen component entirely
component.DrawContent(canvas);
}
}
| Component Count | Off-Screen Skip Rate | Performance Gain |
|---|---|---|
| 100 components | ~60% at typical zoom | 2.5× faster |
| 1,000 components | ~80% at typical zoom | 5× faster |
| 5,000 components | ~90% at typical zoom | 10× faster |
Pre-Render Static Backgrounds to Bitmap Cache
Grid lines, page boundaries, watermarks, and other static background elements change infrequently. Pre-render them to an SKBitmap cache and blit the cached bitmap instead of re-rendering the background on every frame:
Background Bitmap Cache
private SKBitmap _backgroundCache;
private SKRect _cachedViewport;
public void RenderBackground(SKCanvas canvas, SKRect viewport)
{
bool needsRebuild = _backgroundCache == null ||
!_cachedViewport.Contains(viewport);
if (needsRebuild)
{
_backgroundCache?.Dispose();
// Allocate oversized cache to reduce rebuild frequency
var cacheRect = viewport;
cacheRect.Inflate(viewport.Width, viewport.Height);
_cachedViewport = cacheRect;
_backgroundCache = new SKBitmap(
(int)cacheRect.Width, (int)cacheRect.Height);
using var bgCanvas = new SKCanvas(_backgroundCache);
bgCanvas.Clear(SKColors.White);
DrawGrid(bgCanvas, cacheRect);
DrawPageBoundaries(bgCanvas, cacheRect);
}
canvas.DrawBitmap(_backgroundCache, _cachedViewport.Left, _cachedViewport.Top);
}
Bitmap Cache Strategy
Allocate the background cache larger than the current viewport (e.g., 2× viewport size). This allows panning within the cached area without triggering a rebuild. Only rebuild when the user pans beyond the cached region. Combined with viewport culling, this eliminates background rendering overhead entirely for most pan/zoom operations.
Port Layout
Lazy Computation with Dirty Flags
Port positions are only recomputed when a component signals that its port layout is stale. MarkPortsDirty() raises the flag; EnsurePortLayout(() => LayoutPorts()) inside DrawContent runs the layout exactly once while the flag is set, then clears it:
Lazy Port Layout Sequence
// During a drag, bounds changes only mark the layout stale
component.X = newX; // OnBoundsChanged() calls MarkPortsDirty()
component.Y = newY; // no port geometry is computed yet
// The next draw recomputes ports once, then clears the dirty flag
protected override void DrawContent(SKCanvas canvas, DrawingContext context)
{
EnsurePortLayout(() => LayoutPorts());
DrawFlowchartContent(canvas, context);
}
Batch Recompute on Move Operations
When moving multiple components simultaneously (e.g., marquee selection drag), update all positions first and let a single redraw recompute the stale layouts, instead of laying out ports per component:
Batched Port Recompute
public void MoveComponents(IEnumerable<SkiaComponent> components, float dx, float dy)
{
// Phase 1: Update all positions; port layout is only marked dirty
foreach (var component in components)
{
component.X += dx;
component.Y += dy;
}
// Phase 2: One redraw recomputes all stale port layouts exactly once
drawingManager.RequestRedraw();
}
Port Layout Cost by Component Count
| Component Count | Naive Recompute (per frame) | Lazy Recompute (on demand) | Savings |
|---|---|---|---|
| 100 | 0.8 ms | 0.05 ms | 94% |
| 1,000 | 8.2 ms | 0.3 ms | 96% |
| 5,000 | 42 ms | 1.5 ms | 96% |
Connection Lines
Reduce Path Recompilation
Connection lines between components are the most expensive geometry to compute because they involve pathfinding, routing, and arrowhead generation. Cache the line path and only recompute when either endpoint moves:
Connection Path Caching
public class ConnectionLine
{
private SKPath _cachedPath;
private SKPoint _lastSourcePos, _lastTargetPos;
private bool _pathDirty = true;
public void UpdateGeometry()
{
if (!_pathDirty &&
_lastSourcePos == Source.Position &&
_lastTargetPos == Target.Position)
return;
_cachedPath?.Dispose();
_cachedPath = new SKPath();
// Compute routing (orthogonal, direct, or bezier based on family rules)
ComputeRoute(_cachedPath, Source.Position, Target.Position);
_lastSourcePos = Source.Position;
_lastTargetPos = Target.Position;
_pathDirty = false;
}
public void MarkDirty()
{
_pathDirty = true;
}
}
Cache Arrowhead Polygons
Arrowheads are small geometric shapes that don't change unless the line angle changes. Pre-compute them as SKPoint[] arrays and cache per-angle or per-connection:
Arrowhead Caching
private SKPoint[] _cachedArrowhead;
private float _cachedArrowAngle;
private SKPoint[] GetArrowhead(SKPoint tip, float angle, float size)
{
if (_cachedArrowhead != null && Math.Abs(_cachedArrowAngle - angle) < 0.001f)
return _cachedArrowhead;
_cachedArrowhead = new SKPoint[3];
_cachedArrowhead[0] = tip;
_cachedArrowhead[1] = new SKPoint(
tip.X - size * MathF.Cos(angle - 0.5f),
tip.Y - size * MathF.Sin(angle - 0.5f));
_cachedArrowhead[2] = new SKPoint(
tip.X - size * MathF.Cos(angle + 0.5f),
tip.Y - size * MathF.Sin(angle + 0.5f));
_cachedArrowAngle = angle;
return _cachedArrowhead;
}
Limit Animation Effects on Large Diagrams
Animations (selection glow, hover state transition, connection flow particles) add per-frame work proportional to the number of animated elements. Apply thresholds:
Animation Throttling
public bool ShouldAnimate()
{
int componentCount = DrawingManager.ComponentCount;
if (componentCount < 100) return true; // Full animations
if (componentCount < 500) return true; // Reduced animations (no particles)
if (componentCount < 2000) return _isSelected; // Only selected element animates
return false; // No animations at all
}
| Component Count | Animation Policy | Rationale |
|---|---|---|
| < 100 | Full: glow, ripple, particles, shadows | Ample GPU budget for visual polish |
| 100–500 | Reduced: glow + selection only, no particles | Balance between interactivity and aesthetics |
| 500–2,000 | Minimal: selection highlight only | Prioritize frame rate over decoration |
| > 2,000 | None: flat rendering, no animated effects | Pure performance mode |
Large Diagrams
Virtualize Components Outside Viewport
Components that are far outside the viewport should not only be skipped during rendering but also excluded from hit-testing, connection snapping, and spatial index queries:
Viewport-Based Virtualization
public class VirtualizedDrawingManager
{
private List<SkiaComponent> _allComponents;
private List<SkiaComponent> _activeComponents; // Currently in or near viewport
public void UpdateActiveSet(SKRect viewportWorld)
{
var margin = viewportWorld.Width * 0.5f; // Buffer zone
var activeBounds = viewportWorld;
activeBounds.Inflate(margin, margin);
_activeComponents = _allComponents
.Where(c => c.Bounds.IntersectsWith(activeBounds))
.ToList();
}
// All operations use _activeComponents instead of _allComponents
public SkiaComponent HitTest(SKPoint worldPoint)
{
return _activeComponents
.Where(c => c.Bounds.Contains(worldPoint))
.OrderByDescending(c => c.ZOrder)
.FirstOrDefault(c => c.HitTest(worldPoint));
}
}
Use Spatial Index for Hit Testing (Quadtree)
Linear iteration over all components for hit testing becomes a bottleneck above ~1,500 components. Use a quadtree spatial index to reduce hit-test candidates from O(n) to O(log n):
Quadtree Spatial Index
public class QuadtreeSpatialIndex
{
private const int MaxDepth = 8;
private const int MaxItemsPerNode = 16;
private SKRect _bounds;
private List<SkiaComponent> _items;
private QuadtreeSpatialIndex[] _children;
public void Insert(SkiaComponent component)
{
if (_children == null)
{
_items ??= new List<SkiaComponent>();
_items.Add(component);
if (_items.Count > MaxItemsPerNode && _bounds.Width > 1)
Split();
}
else
{
foreach (var child in _children)
{
if (child._bounds.Contains(component.Bounds))
{
child.Insert(component);
return;
}
}
// Spanning multiple children: store at this level
_items ??= new List<SkiaComponent>();
_items.Add(component);
}
}
public List<SkiaComponent> Query(SKPoint point)
{
var results = new List<SkiaComponent>();
if (_items != null)
{
foreach (var item in _items)
{
if (item.Bounds.Contains(point))
results.Add(item);
}
}
if (_children != null)
{
foreach (var child in _children)
{
if (child._bounds.Contains(point))
results.AddRange(child.Query(point));
}
}
return results;
}
}
Large Diagram Performance Guidelines
| Diagram Size | Hit Test Strategy | Render Strategy | Zoom Behavior |
|---|---|---|---|
| < 200 components | Linear scan (simple, fast enough) | Full render all components | No restrictions |
| 200–1,000 | Linear scan with early-return on Z-ordered bounds check | Viewport culling only | No restrictions |
| 1,000–5,000 | Quadtree spatial index | Viewport culling + dirty region tracking | Disable sub-1% zoom (avoid rendering thousands of tiny nodes) |
| > 5,000 | Quadtree + active-set virtualization | All above + background bitmap cache + animation disable | Limit to meaningful zoom range (5%–400%) |
Memory Management
SKPaint Pooling
Instead of each component holding its own SKPaint instances, share common paints through a pool. This dramatically reduces native Skia allocations in large diagrams:
Static Paint Pool
public static class PaintPool
{
private static readonly Dictionary<string, SKPaint> _paints = new();
public static SKPaint GetFill(SKColor color)
{
string key = $"fill_{color.Red}_{color.Green}_{color.Blue}_{color.Alpha}";
if (!_paints.TryGetValue(key, out var paint))
{
paint = new SKPaint
{
Style = SKPaintStyle.Fill,
IsAntialias = true,
Color = color
};
_paints[key] = paint;
}
return paint;
}
public static SKPaint GetStroke(SKColor color, float width)
{
string key = $"stroke_{color.Red}_{color.Green}_{color.Blue}_{color.Alpha}_{width:F1}";
if (!_paints.TryGetValue(key, out var paint))
{
paint = new SKPaint
{
Style = SKPaintStyle.Stroke,
IsAntialias = true,
Color = color,
StrokeWidth = width
};
_paints[key] = paint;
}
return paint;
}
public static void Clear()
{
foreach (var paint in _paints.Values)
paint.Dispose();
_paints.Clear();
}
}
SKPath Reuse
Use SKPath.Reset() instead of creating new paths. Pre-allocate a pool of reusable paths for temporary operations:
Path Reuse Pattern
// ThreadStatic for thread safety in parallel rendering scenarios
[ThreadStatic]
private static SKPath _tempPath;
private static SKPath GetTempPath()
{
if (_tempPath == null)
_tempPath = new SKPath();
else
_tempPath.Reset();
return _tempPath;
}
// Usage in DrawContent
public override void DrawContent(SKCanvas canvas)
{
var path = GetTempPath();
path.AddRoundRect(new SKRect(0, 0, Width, Height), CornerRadius, CornerRadius);
canvas.DrawPath(path, FillPaint);
// _tempPath will be Reset() on next call
}
Dispose Patterns
Every component that creates Skia resources must properly dispose them. The standard pattern:
Complete Dispose Pattern
private bool _disposed;
protected virtual void Dispose(bool disposing)
{
if (_disposed) return;
if (disposing)
{
// Dispose managed SkiaSharp resources
FillPaint?.Dispose();
StrokePaint?.Dispose();
TextPaint?.Dispose();
HighlightPaint?.Dispose();
ShapePath?.Dispose();
TextBlob?.Dispose();
ShadowSurface?.Dispose();
}
// No unmanaged resources to free in typical Skia component
_disposed = true;
}
public override void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
// Guard all operations against disposed state
public override void DrawContent(SKCanvas canvas)
{
if (_disposed)
throw new ObjectDisposedException(nameof(MyComponent));
// ... draw ...
}
Memory Leak Detection
SkiaSharp native objects allocate unmanaged memory. Use a diagnostic tool or a component tracker that logs undisposed components. A common pattern: wrap Dispose() with a debug-mode counter and log warnings when the component count after GC.Collect() doesn't return to zero after a test run.
Profiling & Metrics
Key Performance Metrics
| Metric | Target | Critical Threshold | How to Measure |
|---|---|---|---|
| Frame Time | < 16.6 ms (60 FPS) | > 33 ms (< 30 FPS) | Stopwatch around Render() call |
| Hit Test Time | < 1 ms | > 10 ms | Stopwatch around HitTest() |
| Component Count | 1–5,000 supported | > 10,000 (consider diagram partitioning) | DrawingManager.ComponentCount |
| Port Layout Time | < 0.5 ms per 100 components | > 5 ms per 100 components | Profile LayoutPorts() aggregate |
| Serialization Time | < 500 ms for 1,000 components | > 2 seconds | Stopwatch around Serialize()/Deserialize() |
| Memory (per component) | ~2–5 KB typical | > 20 KB (check for leaks) | Memory profiler (dotMemory, PerfView) |
| GC Collections (Gen 2) | 0 during steady-state rendering | > 0 per frame | GC.CollectionCount(2) delta per frame |
Built-in Performance Counters
Beep.Skia exposes performance counters through DrawingManager.Diagnostics:
Accessing Diagnostic Metrics
var diag = drawingManager.Diagnostics;
Console.WriteLine($"Components: {diag.ComponentCount}");
Console.WriteLine($"Last frame: {diag.LastFrameTimeMs:F2} ms");
Console.WriteLine($"Avg frame: {diag.AverageFrameTimeMs:F2} ms");
Console.WriteLine($"Hit tests/frame: {diag.HitTestsPerFrame}");
Console.WriteLine($"Ports recomputed: {diag.PortsRecomputedThisFrame}");
Console.WriteLine($"Culled components: {diag.CulledComponentCount}");
Console.WriteLine($"Dirty region area: {diag.DirtyRegionFraction:P0} of viewport");
Console.WriteLine($"Connection updates: {diag.ConnectionUpdatesThisFrame}");
Console.WriteLine($"GC pressure: {diag.Gen2CollectionsTotal}");
Component Count Threshold Recommendations
| Scenario | Optimal Range | Performance Notes |
|---|---|---|
| Rapid prototyping / whiteboarding | 1–200 | 60 FPS guaranteed with all visual effects |
| Technical documentation diagrams | 50–500 | 60 FPS with reduced animations at upper end |
| Enterprise architecture maps | 200–2,000 | 60 FPS with spatial indexing and viewport culling |
| Large-scale system maps | 1,000–5,000 | 40–60 FPS with all optimizations enabled |
| Extreme scale (partitioned) | 5,000–10,000 | Consider splitting into multiple linked diagrams |
Performance Budget
At 60 FPS, your total frame budget is 16.6 ms. Allocate roughly: 10 ms for component rendering (DrawContent), 3 ms for connection line rendering, 2 ms for background/overlay rendering, 1.6 ms for hit testing, layout, and other operations. If your diagnostic counters show any category exceeding its budget, focus optimization efforts there first.
Performance Utilities
Two helpers keep the hot path fast: a typeface cache for text and a paint/text helper used across families.
TypefaceCache
Process-wide cache of SKTypeface instances. Font matching is comparatively expensive and components resolve typefaces while drawing, so instances are created once per (family, weight, width, slant) and reused for the lifetime of the process. Cached instances are intentionally never disposed.
| Member | Kind | Description |
|---|---|---|
const string DefaultFamily = "Segoe UI" | Constant | Default font family used when no family is supplied. |
static SKTypeface Get(string family, SKFontStyle style) | Method | Gets a cached typeface for the family and style, falling back to the platform default when the family cannot be resolved. |
static SKTypeface Get(string family) | Method | Gets a cached typeface for the family in the normal style. |
static SKTypeface Get(string family, SKFontStyleWeight weight, SKFontStyleWidth width, SKFontStyleSlant slant) | Method | Gets a cached typeface using explicit style parts (mirrors the four-argument SKTypeface.FromFamilyName overload). |
static SKTypeface Default | Property | The cached default typeface. |
static int CachedCount | Property | Number of distinct typefaces currently cached (diagnostics/tests). |
SkiaUtil
Provides utility methods for SkiaSharp operations including geometric transformations, color space conversions, and mathematical calculations.
| Member | Kind | Description |
|---|---|---|
static SKPoint Rotate(SKPoint point, float degrees) | Method | Rotates a point around the origin by the specified number of degrees. The point to rotate. The rotation angle in degrees. The rotated point. |
static SKPoint Add(SKPoint point1, SKPoint point2) | Method | Adds two points together component-wise. The first point. The second point. The sum of the two points. |
static SKPoint Subtract(SKPoint point1, SKPoint point2) | Method | Subtracts the second point from the first point component-wise. The first point. The second point to subtract. The difference between the two points. |
static float CrossProduct(SKPoint v1, SKPoint v2) | Method | Calculates the cross product (also known as the z-component of the 3D cross product) of two 2D vectors. The first vector. The second vector. The cross product value. |
static (float Hue, float Saturation, float Luminosity) ToHsl(this SKColor color) | Method | Converts an SKColor to HSL (Hue, Saturation, Luminosity) color space. The SKColor to convert. A tuple containing Hue (0-360), Saturation (0-1), and Luminosity (0-1). |
static SKColor FromHsl(float hue, float saturation, float luminosity) | Method | Creates an SKColor from HSL (Hue, Saturation, Luminosity) values. The hue component (0-360 degrees). The saturation component (0-1). The luminosity component (0-1). The corresponding SKColor. |