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.

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 CountOff-Screen Skip RatePerformance Gain
100 components~60% at typical zoom2.5× faster
1,000 components~80% at typical zoom5× faster
5,000 components~90% at typical zoom10× 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 CountNaive Recompute (per frame)Lazy Recompute (on demand)Savings
1000.8 ms0.05 ms94%
1,0008.2 ms0.3 ms96%
5,00042 ms1.5 ms96%

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 CountAnimation PolicyRationale
< 100Full: glow, ripple, particles, shadowsAmple GPU budget for visual polish
100–500Reduced: glow + selection only, no particlesBalance between interactivity and aesthetics
500–2,000Minimal: selection highlight onlyPrioritize frame rate over decoration
> 2,000None: flat rendering, no animated effectsPure 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 SizeHit Test StrategyRender StrategyZoom Behavior
< 200 componentsLinear scan (simple, fast enough)Full render all componentsNo restrictions
200–1,000Linear scan with early-return on Z-ordered bounds checkViewport culling onlyNo restrictions
1,000–5,000Quadtree spatial indexViewport culling + dirty region trackingDisable sub-1% zoom (avoid rendering thousands of tiny nodes)
> 5,000Quadtree + active-set virtualizationAll above + background bitmap cache + animation disableLimit 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

MetricTargetCritical ThresholdHow to Measure
Frame Time< 16.6 ms (60 FPS)> 33 ms (< 30 FPS)Stopwatch around Render() call
Hit Test Time< 1 ms> 10 msStopwatch around HitTest()
Component Count1–5,000 supported> 10,000 (consider diagram partitioning)DrawingManager.ComponentCount
Port Layout Time< 0.5 ms per 100 components> 5 ms per 100 componentsProfile LayoutPorts() aggregate
Serialization Time< 500 ms for 1,000 components> 2 secondsStopwatch 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 frameGC.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

ScenarioOptimal RangePerformance Notes
Rapid prototyping / whiteboarding1–20060 FPS guaranteed with all visual effects
Technical documentation diagrams50–50060 FPS with reduced animations at upper end
Enterprise architecture maps200–2,00060 FPS with spatial indexing and viewport culling
Large-scale system maps1,000–5,00040–60 FPS with all optimizations enabled
Extreme scale (partitioned)5,000–10,000Consider 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.

MemberKindDescription
const string DefaultFamily = "Segoe UI"ConstantDefault font family used when no family is supplied.
static SKTypeface Get(string family, SKFontStyle style)MethodGets 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)MethodGets a cached typeface for the family in the normal style.
static SKTypeface Get(string family, SKFontStyleWeight weight, SKFontStyleWidth width, SKFontStyleSlant slant)MethodGets a cached typeface using explicit style parts (mirrors the four-argument SKTypeface.FromFamilyName overload).
static SKTypeface DefaultPropertyThe cached default typeface.
static int CachedCountPropertyNumber of distinct typefaces currently cached (diagnostics/tests).

SkiaUtil

Provides utility methods for SkiaSharp operations including geometric transformations, color space conversions, and mathematical calculations.

MemberKindDescription
static SKPoint Rotate(SKPoint point, float degrees)MethodRotates 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)MethodAdds two points together component-wise. The first point. The second point. The sum of the two points.
static SKPoint Subtract(SKPoint point1, SKPoint point2)MethodSubtracts 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)MethodCalculates 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)MethodConverts 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)MethodCreates 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.