Attribution: This article was based on content by @wiso on GitHub.
Original: https://github.com/wieslawsoltes/SparseStrips

Introduction

A recent Show HN post from Wieslaw Soltes introduced high-performance .NET bindings for the Vello “Sparse Strips” CPU renderer (GitHub: SparseStrips). That project shows a growing pattern: low-level, high-performance native libraries (often written in Rust or C/C++) exposed to .NET via carefully crafted foreign-function interfaces (FFI). For graphics work—where memory layout, latency, and throughput matter—implementing bindings that avoid needless copies and garbage-collector (GC) pressure is essential.

This article explains how to design .NET bindings for a CPU vector renderer like Vello’s Sparse Strips. It covers interop basics, vector-raster trade-offs, concrete strategies to minimize allocations, cross-platform packaging, and practical recommendations you can apply today.

Background: P/Invoke (Platform Invocation) is .NET’s mechanism to call native libraries using DllImport and well-defined data layouts and calling conventions.

Key Takeaways

  • Design native APIs to use opaque handles and buffers you can reuse from managed code to avoid frequent allocations.
  • Use blittable types, pinning, and Span<T>/Memory<T> for zero-copy transfers and low GC pressure (Toub (2018)).
  • Prefer a clear ownership model (who frees what) and expose deterministic disposal (IDisposable) on managed wrappers.
  • Benchmark both throughput and latency; measure GC allocs with BenchmarkDotNet and dotnet-trace.
  • Package native runtimes per OS/RID and include CI to produce platform-specific binaries.

Credit: This article is inspired by the Show HN post and repo: Wieslaw Soltes, SparseStrips (GitHub).

Background

Two domains intersect here: .NET interop and vector graphics rasterization. You need basic fluency in both to create fast, safe bindings.

  • .NET interop fundamentals: P/Invoke (DllImport), calling conventions (Cdecl vs Stdcall), blittable vs non-blittable types, and strategies for managing memory across managed/unmanaged boundaries (Microsoft (2023)).

    Background: A blittable type has identical in-memory representation on managed and native sides, so it can be passed without marshaling.

  • Vector graphics basics: paths (sequence of lines and curves), fills, strokes, transforms, and anti-aliasing. Rasterization converts those vector primitives into pixels; techniques vary from naive per-pixel scans to tiled or sparse processing (Akenine-Möller et al. (2018)).

    Background: A sparse-strip renderer processes only the horizontal strips (runs) where geometry affects pixels, reducing wasted work and memory traffic.

  • Language/ABI considerations: 32/64-bit pointer sizes, structure packing, and calling conventions differ across platforms; designing a stable C-compatible API (extern “C”) helps portability (The Rustonomicon (2019)).

Main Content

How Sparse Strips influences binding design

Sparse strip rasterizers target only regions with geometry. This means the managed side can allocate and reuse buffers sized to the “sparse” output and pass pointers into native code. That design favors zero-copy workflows where native rasterization writes directly into a pre-allocated pixel buffer.

Key design consequences:

  • Expose a native API that accepts a pointer to a pixel buffer and its stride/extent.
  • Keep path geometry in a compact native format or allow the managed side to build native geometry buffers once and reuse handles.
  • Avoid per-frame allocations of arrays that need marshaling.

API patterns: opaque handles and explicit ownership

A robust pattern is handle-based resource management. Native code returns an opaque handle (an integer or pointer) for heavy objects (canvas, path, atlas). The managed wrapper owns that handle and implements IDisposable to call the native free function.

Example C API shape:

1
2
3
4
5
// C (native)
typedef void* vello_context_t;
vello_context_t vello_create_context(int width, int height);
void vello_destroy_context(vello_context_t ctx);
int vello_render(vello_context_t ctx, const uint8_t* pixel_buffer, int stride, ...);

Corresponding C# (P/Invoke) sketch:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
[DllImport("vello_native", CallingConvention = CallingConvention.Cdecl)]
private static extern IntPtr vello_create_context(int width, int height);

[DllImport("vello_native", CallingConvention = CallingConvention.Cdecl)]
private static extern void vello_destroy_context(IntPtr ctx);

// Managed wrapper
public sealed class VelloContext : IDisposable {
    private IntPtr _ctx;
    public VelloContext(int w, int h) => _ctx = vello_create_context(w, h);
    public void Dispose() => vello_destroy_context(_ctx);
}

This keeps the managed surface mirror simple and avoids deep object graph marshaling.

Zero-copy: Span, pinning, and native memory

To minimize copies, pass pointers to buffers you control. Options:

  • Pin managed arrays/Memory using GCHandle.Alloc or fixed and pass the pointer.
  • Allocate native memory once (Marshal.AllocHGlobal) and wrap it with Span<T> or Memory<T> for managed access.
  • Use UnmanagedCallersOnly (for reverse calls on modern runtimes) if you need native->managed callbacks (Microsoft (2023)).

Example zero-copy sketch:

1
2
3
4
5
6
7
8
// Allocate native buffer once
IntPtr nativePixels = Marshal.AllocHGlobal(width * height * 4);

// Wrap in Span<byte> using unsafe
Span<byte> pixels = new Span<byte>((void*)nativePixels, width * height * 4);

// Pass pointer to native render function
vello_render(_ctx, nativePixels, stride, ...);

Prefer reusing nativePixels for consecutive frames to avoid GC churn.

Caveat: pinning many or large managed objects for long durations can fragment the GC heap; favor native allocations for large, long-lived buffers (Toub (2018)).

Marshaling geometry: serialized buffers vs callback builders

Two practical approaches to transfer path data:

  1. Serialized buffer approach: Pack numeric commands (verbs/coords) into a contiguous native buffer (blittable) and pass pointer/length. This is efficient and predictable.
  2. Builder callbacks: Recreate geometry by calling into native builder functions for each path command. This is simpler but can be slower due to many small calls and marshaling overhead.

For high throughput prefer the serialized buffer.

Threading and parallelism

If the native renderer is multi-threaded, ensure the managed layer doesn’t concurrently mutate buffers that native threads read. Use explicit ownership and synchronization primitives. You may provide a single-threaded contract (render thread owns buffers) that reduces complexity. If exposing callbacks from native into managed code, prefer modern reverse-interop mechanisms and mark delegates with UnmanagedFunctionPointer or use UnmanagedCallersOnly for performance.

Error handling and diagnostics

Return error codes or a status object from native to avoid exceptions crossing boundaries. Log errors nearby the boundary and translate them to managed exceptions only when necessary. Include diagnostics: timing, allocated bytes, and counts of strips processed; these metrics help tune strip granularity and anti-aliasing settings.

Examples/Applications

  1. Real-time financial charting

    • Problem: Thousands of changing vector primitives every second, with sub-60ms refresh and tight memory constraints.
    • Benefit: Pre-serialize path updates; reuse pixel buffers; native sparse strips avoid scanning empty areas. Low latency and low GC.
  2. Server-side PDF or SVG rasterization for thumbnails

    • Problem: Many small, short-lived renders; GC pressure can kill throughput.
    • Benefit: Pool native contexts and pixel buffers across requests; use native allocations and reuse to minimize allocations per request.
  3. Embedded UI rendering in a desktop app

    • Problem: Smooth UI animations where frame jitter matters.
    • Benefit: Deterministic native-backed surfaces and predictable GC load; multi-threaded rasterization where safe.

Best Practices

  • Use opaque native handles and deterministic disposal (IDisposable) on managed wrappers.
  • Favor blittable buffers and single, pre-allocated pixel buffers for render target data.
  • Allocate large buffers using native allocators and expose them as Span for safe managed access (Toub (2018)).
  • Keep the C ABI stable: extern “C”, explicit sizes, and minimal pointer/ownership semantics (The Rustonomicon (2019)).
  • Bundle native runtimes with NuGet runtime identifiers (RIDs) and automate builds for Windows, Linux, and macOS.
  • Measure: use BenchmarkDotNet for latency/throughput and dotnet-trace/dotnet-counters for GC insights.

Implications

Bindings like these trade complexity for performance and determinism. You accept more unsafe code, stricter ownership contracts, and platform-specific packaging. But for IO-heavy, compute-bound vector rendering, the performance gains can be decisive. Projects such as SkiaSharp show this model scales in production (SkiaSharp (2016)).

From an ecosystem view, the trend toward exposing Rust-native backends via a C API enables robust, fast .NET libraries. Follow ABI best practices and keep the managed API idiomatic to lower the adoption barrier.

Conclusion

The SparseStrips approach to CPU rasterization maps well to .NET when bindings avoid per-call allocations and minimize GC interaction. Use handle-based APIs, pre-allocated buffers, and zero-copy techniques (Span, pinned memory) to keep latency low and throughput high. Prioritize clear ownership, deterministic disposal, and cross-platform packaging. Benchmark early and often—measure not just pixels rendered but allocations and jitter.

Further reading and resources:

  • Microsoft docs on P/Invoke and interop (Microsoft (2023))
  • Stephen Toub on Span and Memory patterns (Toub (2018))
  • The Rustonomicon for FFI safety guidance (The Rustonomicon (2019))
  • Real-time rasterization and rendering fundamentals (Akenine-Möller et al. (2018))
  • SkiaSharp as an example of native-to-.NET graphics bindings (SkiaSharp (2016))

Original source: Wieslaw Soltes (Show HN). GitHub: https://github.com/wieslawsoltes/SparseStrips

Citations

  • Akenine-Möller et al. (2018)
  • Toub (2018)
  • Microsoft (2023)
  • The Rustonomicon (2019)
  • SkiaSharp (2016)

References