Silk.NET (C#)
The .NET ecosystem's mainline Vulkan binding: a vk.xml-generated, unsafe-struct, function-pointer-based thin layer maintained under the .NET Foundation, whose safety story is a handful of opt-in typed conveniences (typed pNext chains, extension objects) layered over an otherwise raw API — the survivor of an ecosystem contest that the genuinely idiomatic wrapper, SharpVk, lost.
| Field | Value |
|---|---|
| Language | C# (.NET Standard 2.0; .NET 5+ for the function-pointer fast path) |
| License | MIT |
| Repository | dotnet/Silk.NET |
| Documentation | dotnet.github.io/Silk.NET · NuGet Silk.NET.Vulkan |
| Category | Thin + typed chains (raw binding with typed-chain conveniences over a thin core) |
| First release | v1.0.0-preview August 4, 2019; v1.0.0 March 6, 2020; v2.0.0 (SilkTouch rewrite) January 2, 2021 |
| Latest release | v2.23.0 (January 22, 2026); 2.x is in ad-hoc maintenance while 3.0 is rewritten |
NOTE
Silk.NET covers far more than Vulkan (OpenGL, DirectX, OpenCL, OpenAL, OpenXR, GLFW, SDL, WebGPU, windowing, input, math). This deep-dive examines only the Silk.NET.Vulkan package family and the SilkTouch machinery underneath it, with SharpVk as historical contrast.
Overview
What it solves
C# sits in an awkward spot for Vulkan: it has a GC and a JIT, but also true value types, raw pointers under unsafe, blittable struct layout control, and (since C# 9 / .NET 5) first-class native function pointers (delegate* unmanaged). The historical pain was the call mechanism: classic [DllImport] P/Invoke cannot reach Vulkan's per-instance/per-device function pointers at all (everything past vkGetInstanceProcAddr must be loaded dynamically), and Marshal.GetDelegateForFunctionPointer adds a managed-delegate indirection plus marshalling stubs on every call.
Silk.NET's answer is a generator pipeline that emits blittable unsafe structs straight from vk.xml and a Roslyn source generator — SilkTouch — that fills in every API method body with a direct function-pointer invocation (calli in IL terms) through a generated VTable that lazily resolves and caches each entry point. The result is a 1:1, pointer-level Vulkan API surface (Vk.CreateInstance(InstanceCreateInfo*, AllocationCallbacks*, Instance*)) plus generated ergonomic overloads (ref readonly / out / Span-based), with no managed wrapper objects on the call path. In spirit it is the C# equivalent of ash (Rust) or erupted (D): a loader plus typed raw declarations, not a safety layer.
Design philosophy
Performance-first, measured at the JIT-assembly level. From the repository README (README.md):
"Having poured lots of hours into examining generated C# code and its JIT assembly, you can count on us to deliver blazing fast bindings with negligible overhead induced by Silk.NET!"
and, on staying current:
"With an efficient bindings regeneration mechanism, we are committed to ensuring our bindings reflect the latest specifications with frequent updates generated straight from the upstream sources."
Safety, by contrast, is opt-in and local: the one place Silk.NET invests real type-system effort is the pNext structure-chain subsystem, where vk.xml's structextends metadata is compiled into generic interface constraints. Everything else — synchronization, handle lifetime, valid usage — is the caller's problem, exactly as in C. The project is currently mid-transition: the README states "We are currently hard at work on Silk.NET 3.0 - the latest and greatest Silk.NET, laser-focused on addressing pain points and reimagining how C# bindings libraries can be done", with 2.x explicitly in volunteer-maintenance mode (README.md).
How it works
Binding generation & API coverage
Silk.NET 2.x bindings are produced by an in-repo generator, Silk.NET.BuildTools (src/Core/Silk.NET.BuildTools), driven by a declarative generator.json at the repo root. The Vulkan task consumes the Khronos registry directly:
// generator.json — the Vulkan profile (abridged)
{
"profileName": "Vulkan",
"sources": [
"https://raw.githubusercontent.com/KhronosGroup/Vulkan-Docs/main/xml/vk.xml"
],
"mode": "ConvertConstruct",
"converter": { "reader": "vk", "constructor": "vk", "className": "Vk" },
"prefix": "vk"
}A dedicated vk reader parses vk.xml (types, commands, enums, extensions, structextends, len attributes), a converter/"bakery" stage normalizes profiles, and an overloader stage mass-produces ergonomic permutations. The committed output is checked into the tree (Vk.gen.cs, Structs/*.gen.cs, Enums/*.gen.cs), so the published package is reproducible and diffable. Each command becomes a partial method declaration carrying registry metadata as attributes — note Count and Flow (the len and in/out direction from vk.xml) surviving as decoration:
// src/Vulkan/Silk.NET.Vulkan/Vk.gen.cs
[NativeApi(EntryPoint = "vkCreateInstance", Convention = CallingConvention.Winapi)]
public unsafe partial Result CreateInstance(
[Count(Count = 0), Flow(FlowDirection.In)] InstanceCreateInfo* pCreateInfo,
[Count(Count = 0), Flow(FlowDirection.In)] AllocationCallbacks* pAllocator,
[Count(Count = 0), Flow(FlowDirection.Out)] Instance* pInstance);The same entry point is emitted in several overloads (pointer, ref readonly, out), so user code rarely needs manual pinning. The SilkTouch Roslyn source generator (src/Core/Silk.NET.SilkTouch, NativeApiGenerator.cs) then fills in every [NativeApi] partial body at the consumer's compile time. Its design (per the November 2020 blog post SilkTouch: Invokes & Marshalling) is an ASP.NET-Core-style middleware pipeline of marshallers (string allocation, span pinning, bool conversion, delegate marshalling — see Middlewares/) around a core that "generates a calli instruction using function pointers". VTableGeneration.cs emits one System.IntPtr slot field per entry point, populated lazily via _ctx.GetProcAddress and reused on every subsequent call — so vkGetInstanceProcAddr/vkGetDeviceProcAddr are hit once per function, not per call.
Coverage is essentially total: core Vulkan plus every registry extension, with vendor extension functions generated into separate per-tag packages (Silk.NET.Vulkan.Extensions.KHR, .Extensions.EXT, …) as classes loaded at runtime (e.g. KhrSwapchain) — see Type-system techniques. Video headers (vk_video.h) are handled by a parallel ClangSharp-based C++ path in BuildTools.
The 3.0 rewrite abandons both the bespoke vk.xml reader and the source-generator delivery. The design proposal (Generation of Library Sources and PInvoke Mechanisms) delegates header parsing to the ClangSharp P/Invoke generator with a "mod" system for metadata injection, and pre-generates code instead of generating in the consumer's build — the proposal concedes the 2.x approach "was too bleeding-edge" and leaned on an immature understanding of source generators. 3.0 also replaces the overload explosion with implicit-converting pointer wrapper types (Ref, Ptr and friends). Notably, moving from vk.xml to Clang-parsed headers loses registry-only metadata (len, externsync, structextends must be re-injected as mods) — a regression worth remembering when designing a D generator.
Handle lifetime & ownership model
There is none, by design. Every dispatchable and non-dispatchable handle is a plain blittable struct around the raw value:
// src/Vulkan/Silk.NET.Vulkan/Structs/Instance.gen.cs
[NativeName("Name", "VkInstance")]
public unsafe partial struct Instance
{
public nint Handle;
}No IDisposable, no finalizer, no ownership tracking, no use-after-destroy detection: forgetting DestroyInstance leaks, double-destroying is UB, and a copied Instance struct is just a copied integer. The only stateful object is the Vk API class itself (Vk.cs), which is a function-table holder, not a resource owner: Vk.GetApi() loads the loader, and assigning vk.CurrentInstance / vk.CurrentDevice swaps the active VTable so subsequent calls dispatch through instance- or device-level function pointers (device-level loading skips the instance dispatch trampoline, the same optimization vulkan-hpp's dispatchers and erupted's loadDeviceLevelFunctions perform). VTables are cached in a ConcurrentDictionary keyed by (Instance?, Device?), and CloneWith() produces an additional Vk view sharing those caches for multi-device/multi-threaded setups. A // TODO in Vk.cs already marks this dictionary machinery for removal in 3.0.
Synchronization safety
Not modeled — full stop, and the absence is informative for a "mainstream-platform thin binding" data point. vkCmdPipelineBarrier2, semaphores, fences, timeline semaphores, and queue-family ownership transfers are exposed as raw generated calls with raw generated structs; nothing distinguishes them from any other entry point. There is no render graph, no automatic barrier placement, no typed encoding of pipeline stages vs. access flags (both are plain [Flags] enums, so srcStageMask/srcAccessMask mismatches compile fine), and no Send/Sync-style thread-affinity typing — C# has no such mechanism to offer.
The externsync attribute in vk.xml does not survive generation: it appears neither as an attribute on parameters (the way Count/Flow do) nor in documentation comments — most generated members carry the placeholder /// <summary>To be documented.</summary>. A user cannot tell from the binding that vkQueueSubmit requires external synchronization of the VkQueue while vkGetDeviceQueue does not. Ironically, the binding layer itself is internally thread-safe where it has shared state: Vk.cs guards its extension-presence caches with a ReaderWriterLockSlim and its VTable/physical-device caches with ConcurrentDictionary/Interlocked.CompareExchange — careful engineering spent on the loader's own hash maps, with zero carried over to the API's synchronization contract. Correctness is delegated wholesale to the validation layers and synchronization validation (see sync-validation).
Type-system techniques
The flagship technique — and the part most transferable to a D design — is the typed pNext chain subsystem, where vk.xml's structextends metadata is compiled into a three-interface hierarchy (IChainable.cs, IChainStart.cs, IExtendsChain.cs):
// src/Vulkan/Silk.NET.Vulkan/IExtendsChain.cs
/// <summary>
/// Marks a chainable struct indicating which chain this type extends.
/// </summary>
public interface IExtendsChain<out TChain> : IChainable
where TChain : unmanaged, IChainable
{ }Every generated struct that the registry says may start a chain implements IChainStart; every struct with a structextends entry implements one IExtendsChain<TChain> per legal parent:
// src/Vulkan/Silk.NET.Vulkan/Structs/PhysicalDeviceVulkan12Features.gen.cs
public unsafe partial struct PhysicalDeviceVulkan12Features :
IExtendsChain<PhysicalDeviceFeatures2>,
IExtendsChain<PhysicalDeviceFeatures2KHR>,
IExtendsChain<DeviceCreateInfo>
{ ... }The extension methods in ChainExtensions.cs then make illegal chains unrepresentable at compile time: chain.AddNext(out TNext next) is constrained where TNext : unmanaged, IExtendsChain<TChain>, so attaching PhysicalDeviceVulkan12Features to a BufferCreateInfo chain is a compile error. The methods also auto-set every SType (via IStructuredType.StructureType(), which "also ensures it is set to the correct value") and splice PNext pointers. The layout contract is documented on IChainable itself:
"Note that any structure marked
IChainablemust start with aStructureTypeand avoid*field, in that order. This is so that a pointer to it can be coerced to a pointer to aBaseInStructure." —IChainable.cs
A deliberate escape hatch exists for registry gaps: parallel *Any methods constrained only on IChainable, documented as "The Any versions of chain methods do not validate that items belong in the chain, this is useful for situations where the specification does not indicate required chain constraints." (ChainExtensions.cs). On top of the by-ref stack-based extension methods sits a managed Chain class family (Chain.cs, T4-generated arities in Chain.g.cs) that owns a single contiguous unmanaged allocation for a whole chain — convenient, heap-allocating, IDisposable.
Beyond chains, the typing is modest:
- Distinct handle structs (
Instance,Buffer,DeviceMemory, …) prevent cross-handle confusion that rawulongs would allow — but there is no phantom/branding to distinguish handles from different devices, and no linear/affine ownership (C# cannot express it). - Typed enums and
[Flags]bitmasks from the registry;Bool32wrapsVkBool32. - Extension capability typing, runtime-checked: extension functions live in separate classes (
KhrSwapchain,ExtDebugUtils) obtained viavk.TryGetDeviceExtension(instance, device, out KhrSwapchain ext)— possessing the object is weak evidence the extension was loaded. It is advisory only:Vk.cs'sIsExtensionPresentwarns "This function doesn't check that the extension is enabled — you will get an error later on if you attempt to call an extension function from an extension that isn't loaded." - Builder typestate, lifetimes, branded types: absent. Struct initialization uses generated constructors with optional parameters that default
STypecorrectly — the C# analogue of designated initializers, not a typestate builder.
Overhead & escape hatches
The overhead story is the project's calling card, and it is genuinely thin for a GC'd-language binding:
| Cost center | Silk.NET 2.x answer |
|---|---|
| Call dispatch | SilkTouch-generated direct function-pointer invocation (calli); no managed delegates, no reflection, no DllImport stubs |
| Function resolution | One IntPtr slot per entry point in a generated VTable, lazily filled via GetProcAddress, then cached (VTableGeneration.cs) |
| Struct marshalling | None for Vulkan structs — all blittable unsafe structs, identical layout to C; pointer overloads pass straight through |
| Convenience overloads | ref/out/Span overloads pin or take refs; string parameters allocate/copy via SilkMarshal (avoidable by using byte* overloads) |
| Chain helpers | By-ref ChainExtensions work on stack structs (no allocation); the managed Chain class allocates one unmanaged block + a managed wrapper |
| Loader bookkeeping | ConcurrentDictionary VTable cache and ReaderWriterLockSlim-guarded extension caches — touched on context switches and extension queries, not per call |
Residual costs are the JIT/GC platform itself (a cli/calli transition still erects a P/Invoke frame; .NET cannot inline across the native boundary) and the convenience-overload marshalling when chosen. Escape hatches are trivially available because the floor is raw: every function has a pure-pointer overload, every handle exposes its Handle field for interop with other libraries, and Vk can be constructed over a custom INativeContext (e.g. a LamdaNativeContext wrapping someone else's vkGetInstanceProcAddr), which is how Silk.NET interoperates with externally created instances. The unusual direction of the escape hatch is worth noting: in Vulkano-style wrappers you escape down to raw handles; in Silk.NET you opt up into the typed chain helpers.
Error handling & validation integration
Vulkan's VkResult is exposed as the Result enum and returned raw — no exceptions, no Expected-style sum type, no [[nodiscard]] analogue; an ignored error is silently ignored (C#'s unused-return warning does not apply to enum returns). User code conventionally writes a ThrowIfFailed-style helper or pattern-matches. This is the polar opposite of SharpVk, which translated every non-success VkResult into a thrown SharpVkException.
Validation integration is "bring your own layers": the bindings expose VK_EXT_debug_utils (typed PfnDebugUtilsMessengerCallbackEXT function-pointer wrappers, ExtDebugUtils extension class) so applications can register messenger callbacks, and Silk.NET ships a Silk.NET.Vulkan.SwiftShader.Native package (a bundled CPU ICD) useful for CI hosts without GPUs. There is no binding-level valid-usage checking of any kind; the Khronos validation layers are the assumed development-time backstop.
SharpVk: the idiomatic predecessor that lost
FacticiusVir/SharpVk (2016–2019, MIT) was the earlier, opposite bet: an idiomatic C# Vulkan wrapper, also generated from vk.xml, that modeled each handle as a managed class with instance methods (device.CreateBuffer(...) returning a Buffer object), hid pointers behind marshalling glue, translated VkResult into thrown SharpVkExceptions, and even bundled a LINQ-to-SPIR-V experiment ("Shanq"). It is the C# analogue of what Vulkano is to ash — minus the synchronization tracking, which SharpVk never attempted.
It is effectively abandoned: the last release (0.4.2) dates to January 2018, the last push to December 2022, and the repo (156 stars) never reached Vulkan 1.2 coverage. The failure mode is instructive for any "make it idiomatic" design:
- Per-call marshalling cost. Class-per-handle plus managed↔native translation on every call put allocations and copies on the hottest path of an API whose entire premise is CPU-overhead elimination.
- Generator treadmill. An idiomatic surface multiplies the per-
vk.xml-release maintenance: every new extension needs hand-tuned shaping decisions, where a thin generator just re-runs. A volunteer project could not keep up; Silk.NET's "regenerate from upstreammain" pipeline could. - The ecosystem chose thin+fast. Engine-minded .NET users (Stride, Veldrid-adjacent, evergine) standardized on raw-style bindings (Silk.NET, or
Vortice.Vulkan) and built their own abstractions above, exactly mirroring Rust's drift fromvulkanotowardash.
Strengths
- Negligible call overhead for a managed language: SilkTouch's
calli-based dispatch through a cached VTable is the measured-at-the-JIT-assembly design the README advertises; no delegates or reflection on the call path. - Typed
pNextchains are a genuine innovation:IChainStart/IExtendsChain<TChain>mechanically compilevk.xmlstructextendsinto generic constraints, making invalid chains a compile error while auto-maintainingSType/PNext— with a documented*Anyescape hatch. - Total, fast-refreshing coverage: bindings regenerate from Khronos
main, extensions included, with checked-in diffable output. - Raw floor always available: pointer overloads, public
Handlefields, and customINativeContextmake interop with external Vulkan code trivial. - Institutional backing: .NET Foundation project under the
dotnetorg — unusual longevity insurance for a bindings library. - Ecosystem winner: the de-facto standard .NET Vulkan binding, validated against the abandoned idiomatic alternative.
Weaknesses
- No safety beyond chains: lifetime, synchronization, valid usage, and error checking are all caller responsibilities;
Resultreturns are ignorable. externsyncand most registry semantics are dropped:Count/Flowattributes survive as inert decoration,externsyncnot at all; generated docs are largelyTo be documented.- The
Vkcontext object is awkward state:CurrentInstance/CurrentDevicesetters mutate global-ish dispatch state, and the(Instance?, Device?)-keyed cache is acknowledged in-source as a 3.0 removal target. - 2.x is in maintenance limbo: the README states 2.x investment is "currently limited" to ad-hoc volunteer releases (one release between November 2024 and January 2026) while 3.0 is rewritten — and 3.0 has been "coming" since 2022.
- Source-generator delivery proved fragile: SilkTouch 2.x runs in the consumer's compile (slow, "abuses what Source Generators are supposed to do" per its own blog post); 3.0 retreats to pre-generated code.
- Overload explosion: emitting every pointer/
ref/out/Spanpermutation per entry point bloats the API surface and IntelliSense; 3.0'sRef/Ptrimplicit-conversion wrappers are the planned fix.
Key design decisions and trade-offs
| Decision | Rationale | Trade-off |
|---|---|---|
| Thin raw API, not an idiomatic wrapper | Zero abstraction tax; survives the vk.xml release treadmill via full regeneration | All Vulkan hazards (sync, lifetime, VU) pass through to the user |
SilkTouch source generator emitting calli function-pointer calls | Eliminates delegate/DllImport overhead; per-entry-point lazy VTable caching | Generator runs in consumer builds, slow and brittle — reversed (pre-generated) in 3.0 |
Handles as blittable nint-wrapping structs | Free interop, no GC pressure, distinct types prevent handle mix-ups | No ownership/lifetime tracking; double-destroy and use-after-free are uncaught |
structextends → IChainStart/IExtendsChain<TChain> constraints | Compile-time-valid pNext chains with auto-SType; the registry metadata does real work | Only this one slice of registry semantics is typed; *Any methods can bypass it |
externsync discarded during generation | Simpler generator; C# offers no ownership system to attach it to | Threading contract invisible in types and docs; validation layers are the only net |
Raw Result returns, no exceptions | No hidden control flow or allocation on the call path (contrast SharpVk) | Errors are silently ignorable; every user reinvents ThrowIfFailed |
| Extension functions in runtime-loaded per-tag classes | Object possession ≈ capability evidence; keeps core package lean | Runtime-checked only (reflection + Activator.CreateInstance); presence ≠ enabled, per its own docs |
3.0: ClangSharp headers + "mods" instead of the vk.xml reader | Reuses a maintained industrial parser; one pipeline for all APIs | Registry-only metadata (len, structextends, externsync) must be re-injected manually |
Sources
- dotnet/Silk.NET — GitHub repository
README.md— overhead claim, 3.0 status, maintainershipgenerator.json— Vulkan profile sourcingvk.xmlfrom Khronosmainsrc/Core/Silk.NET.BuildTools— readers/converters/overloaderssrc/Core/Silk.NET.SilkTouch/NativeApiGenerator.cs— the source generator ·VTableGeneration.cs— per-entry-point slots ·Middlewares/— marshalling pipeline- SilkTouch: Invokes & Marshalling — Silk.NET blog, November 2020
- Proposal — Generation of Library Sources and PInvoke Mechanisms (3.0)
Vk.cs— context, VTable swapping, extension caches ·Vk.gen.cs— generated[NativeApi]declarationsIChainable.cs·IChainStart.cs·IExtendsChain.cs·IStructuredType.cs·ChainExtensions.cs·Chain.csStructs/Instance.gen.cs— handle struct ·Structs/PhysicalDeviceVulkan12Features.gen.cs—IExtendsChainin the wild- Silk.NET.Vulkan on NuGet
- FacticiusVir/SharpVk — the abandoned idiomatic wrapper
- Related:
ash(Rust) ·erupted(D) ·vulkan-hpp(C++) · Vulkano (Rust) · LWJGL & vulkan4j (Java) · Synchronization validation · Comparison · Survey index