About these notes
Not affiliated with Ahwoo Limited or RocketWerkz. Covers the Vulkan bindings, SDL3 instead of GLFW, Dear ImGui on Vulkan, and macOS on Apple silicon. Tested on an M1 Max (macOS, Vulkan through MoltenVK) unless a section says otherwise; Windows and Linux are untested.
Checked on November 7, 2026, against the 2026.10.3 releases of BRUTAL's Vulkan, Vulkan.Abstractions, Sdl, ShaderC and ImGui packages. BRUTAL moves fast: where these notes disagree with its source or its samples, the source and samples are right. Questions about BRUTAL go to its Guides and Help forum.
Start with the official samples
In late October 2026, BRUTAL's repo gained samples: one small file per package in samples/Hello, and fuller programs in samples/Demo. They're the place to start. Each is a single C# file you run with the .NET 10 SDK, no project needed:
git clone https://github.com/ahwootech/BRUTAL.git
cd BRUTAL/samples/Hello
dotnet run HelloVulkan.csOn a Mac, install a Vulkan loader first (macOS).
For Vulkan, the samples README suggests Hello/HelloVulkan.cs and then Demo/DemoVulkan.cs: a triangle on GLFW, with the Vulkan.Abstractions helpers, VMA (the Vulkan Memory Allocator) and ShaderC.
These notes cover what the samples don't: SDL3 with Vulkan, Dear ImGui on Vulkan with SDL3, and macOS on Apple silicon.
Five things for day one
- Add
using Brutal.VulkanApi.Abstractions;Many everyday helpers (CreateFence,WaitForFence,AllocateCommandBuffer, typedPushConstants) are extension methods in that namespace. Without theusing, they look like they don't exist. - Using Dear ImGui? Reset
ImString.SharedStorageonce a frameOr the app eventually crashes. Details. - To find a Vulkan call, search for its C nameLook for
vkCmdBlitImage, say, inBrutal.Vulkan/Generated/Extensions/. The XML docs there carry the Vulkan spec text. Queue calls likeSubmit2andPresentKHRare on theQueueclass, inBrutal.Vulkan/Manual/Classes/Queue.cs. - On a Mac, install a Vulkan loader yourselfHow.
- Pass spans, not arraysCommand wrappers take
ReadOnlySpan<T>, so hand themCollectionsMarshal.AsSpan(list). A per-frameToArray()of a few thousand copy regions (56 bytes each) lands on the large object heap and can trigger gen-2 garbage collections.
The packages
| Package | Used for | Namespace |
|---|---|---|
Ahwoo.BRUTAL.Vulkan | Vulkan bindings | Brutal.VulkanApi |
Ahwoo.BRUTAL.Vulkan.Abstractions | Instance and device setup, fences, command buffers, push constants | Brutal.VulkanApi.Abstractions |
Ahwoo.BRUTAL.Sdl | Window, events, gamepads, the Vulkan surface (SDL3) | Brutal.SdlApi |
Ahwoo.BRUTAL.ShaderC | GLSL to SPIR-V at runtime | Brutal.ShaderCApi |
Ahwoo.BRUTAL.ImGui, .ImGui.Backends | Dear ImGui (docking branch) and its Vulkan renderer (.ImGui.Backends also brings in BRUTAL's Glfw and OpenGl packages) | Brutal.ImGuiApi, Brutal.ImGuiApi.Backends |
Ahwoo.BRUTAL.Core, .Numerics (they come in with the others) | ByteSize, Ptr, float2, float4 | Brutal, Brutal.Pointers (for Ptr), Brutal.Numerics |
The native libraries for SDL, ShaderC and ImGui come with the packages, for Windows x64, Linux x64 and macOS arm64. The Vulkan loader doesn't: BRUTAL's samples README says it comes with your GPU driver on Windows and that you install it on Linux. For macOS, see below.
The code in these notes takes addresses and uses stackalloc pointers, so it goes inside an unsafe method or block, with <AllowUnsafeBlocks>true</AllowUnsafeBlocks> in your project. The official samples do the same.
Reading the Vulkan bindings: C to C#
Once these patterns click, the Vulkan spec reads almost straight across.
Calls and structs
| In C | In BRUTAL |
|---|---|
vkCreateBuffer(device, &info, NULL, &buffer) | var buffer = device.CreateBuffer(info, null); |
info.sType = VK_STRUCTURE_TYPE_… | Filled in for you |
info.pNext = &next | info.Next = &next |
colorAttachmentCount + pColorAttachments | ColorAttachmentCount + ColorAttachments (no p) |
vkCmdSetViewport(cmd, 0, 1, &viewport) | cmd.SetViewport(0, [viewport]) |
vkQueueSubmit2(queue, 1, &submit, fence) | queue.Submit2([submit], fence) |
if (result != VK_SUCCESS) … | Errors throw VkResultError |
Calling vkGetSwapchainImagesKHR twice | GetSwapchainImagesKHR_Count(…), then GetSwapchainImagesKHR_Write(…, span) |
vkMapMemory | device.MapMemory(new MapInfo(memory)) returns a Ptr; MapMemory<T> returns a Ptr<T> |
Names and types
| In C | In BRUTAL |
|---|---|
VK_FORMAT_R8G8B8A8_SRGB | VkFormat.R8G8B8A8SRGB |
VK_FORMAT_B8G8R8A8_UNORM | VkFormat.B8G8R8A8UNorm |
VK_IMAGE_TYPE_2D | VkImageType._2D |
VK_SAMPLE_COUNT_1_BIT | VkSampleCountFlags._1Bit |
VkBool32 | Bool32 (true and false convert) |
VkDeviceSize | ByteSize64, e.g. new ByteSize64(8); a ByteSize converts to it implicitly |
A timeout of UINT64_MAX | nint.MaxValue (the parameter is a signed nint: still about 292 years in nanoseconds) |
A few exceptions
- An out-of-date swapchain throws.
AcquireNextImageKHRandPresentKHRreturn aVkResultfor results that aren't errors (SuboptimalKHR, and for AcquireTimeoutandNotReady), and Acquire hands back the image index as anoutparameter.ErrorOutOfDateKHRis an error, so it throws like the rest: catch it withcatch (VkResultError e) when (e.Result == VkResult.ErrorOutOfDateKHR). Checking the returned result for it never matches. - Some counts are filled in for you. You can't set
VkCommandBufferAllocateInfo.CommandBufferCount;device.AllocateCommandBuffer(info)sets it. - Push constants are the size exception. Their offsets and sizes take
ByteSize32, notByteSize64. - Fixed-size arrays index directly, both inline arrays (
region.SrcOffsets[1] = …) and vector fields (clear.Float32is afloat4, soclear.Float32[0] = …). Name strings like a device'sDeviceNameare C#fixedbuffers: copy the properties struct fromGetProperties()to a local first. - A few calls take a pointer where you might expect
in, likecmd.ClearColorImage(image, layout, &color, ranges). - When an enum name doesn't compile, search
Brutal.Vulkan/Generated/Enumsfor the C name. Some spellings to watch for:R16G16B16A16SFloat,D32SFloat,R8UInt,VkColorSpaceKHR.SRGBNonlinearKHR,VkQueryResultFlags._64Bit.
A working device setup
Tested on macOS; written to run on Windows and Linux too, but untested there. It uses the Vulkan.Abstractions helpers, which also handle the macOS portability pieces.
using Brutal.SdlApi;
using Brutal.VulkanApi;
using Brutal.VulkanApi.Abstractions;
// Instance: the window system's extensions (SDL's video subsystem must be up; see SDL3 below),
// plus what this platform needs.
var extensions = new HashSet<string>(Sdl.Vulkan.GetInstanceExtensions());
VulkanHelpers.AddPlatformRequiredExtensions(extensions); // portability enumeration on macOS
var layers = new HashSet<string>();
if (VulkanHelpers.Validation.IsSupported)
VulkanHelpers.AddValidationLayers(layers);
var instance = Vulkan.CreateInstance(new Instance.CreateInfo
{
AppInfo = new VkApplicationInfo { ApiVersion = (int)VulkanHelpers.Api.VERSION_1_3 },
Flags = VulkanHelpers.GetPlatformRequiredInstanceCreateFlags(),
EnabledExtensions = extensions,
EnabledLayers = layers,
});
// Device: one queue that does graphics and compute.
var gpu = instance.PhysicalDevices[0]; // pick properly in real code
var family = gpu.FindQueueFamily(VkQueueFlags.GraphicsBit | VkQueueFlags.ComputeBit);
var deviceExtensions = new HashSet<string>();
VulkanHelpers.AddSwapchainExtensions(deviceExtensions);
VulkanHelpers.AddMandatoryIfSupportedDeviceExtensions(deviceExtensions, gpu); // VK_KHR_portability_subset
var v13 = new VkPhysicalDeviceVulkan13Features { DynamicRendering = true, Synchronization2 = true };
var v12 = new VkPhysicalDeviceVulkan12Features { Next = &v13, ScalarBlockLayout = true };
var features = new VkPhysicalDeviceFeatures();
var priority = 1f;
Span<VkDeviceQueueCreateInfo> queues =
[
new VkDeviceQueueCreateInfo { QueueFamilyIndex = family, QueueCount = 1, QueuePriorities = &priority },
];
DeviceEx device = gpu.CreateDevice(new DeviceEx.CreateInfo
{
Extensions = deviceExtensions,
Features = &features,
QueueCreateInfos = queues,
Next = &v12,
});
var queue = device.GetQueue(family, 0);CreateDevice from Vulkan.Abstractions returns a DeviceEx, a device wrapper with extra helpers. Keep it: ImGui's Vulkan backend asks for one.
Two features that validation flags when they're missing
IndependentBlend(inVkPhysicalDeviceFeatures), if color attachments in one pipeline have different blend states or write masks.ShaderDemoteToHelperInvocation(inVkPhysicalDeviceVulkan13Features), if your GLSL usesdiscardand you compile for Vulkan 1.3 with ShaderC.discardthen compiles toOpDemoteToHelperInvocation, which needs that feature.
Timing the GPU
Timestamp queries work as in C:
var pool = device.CreateQueryPool(new VkQueryPoolCreateInfo { QueryType = VkQueryType.Timestamp, QueryCount = 64 }, null);
cmd.ResetQueryPool(pool, 0, 64);
cmd.WriteTimestamp2(VkPipelineStageFlags2.AllCommandsBit, pool, 0);
// … the work you want to time …
cmd.WriteTimestamp2(VkPipelineStageFlags2.AllCommandsBit, pool, 1);
// After the frame's fence has signalled:
var ticks = stackalloc ulong[2];
var result = device.GetQueryPoolResults(pool, 0, 2, new ReadOnlySpan<byte>(ticks, 16),
new ByteSize64(8), VkQueryResultFlags._64Bit);Multiply the difference in ticks by Limits.TimestampPeriod (from the physical device's properties) for nanoseconds. Check result, since it can be NotReady.
macOS on Apple silicon
BRUTAL's packages bundle native libraries for macOS on Apple silicon, and its samples README lists it beside Windows x64 and Linux x64. Vulkan runs there through MoltenVK, an implementation of Vulkan on Apple's Metal. Three steps:
-
Install a Vulkan loader. BRUTAL looks for
libvulkan.1.dylib, then/opt/homebrew/lib/libvulkan.1.dylib, then/usr/local/lib/libvulkan.1.dylib, then its own VulkanEx helper library, which itself needslibvulkan.1.dylib. It doesn't ship a loader in 2026.10.3. Its source has a TODO to package it (Brutal.Vulkan/ImportResolver.cs), so this step may go away. The samples README suggests the Vulkan SDK. With Homebrew instead:brew install molten-vk vulkan-loader brew install vulkan-validationlayers vulkan-tools # optional: validation, and vulkaninfo -
With SDL3 and Homebrew's loader, tell SDL where the loader is before you create the window. Homebrew's lib folder isn't on the default library path, so without the hint
CreateWindowfails with "Failed to load Vulkan Portability library".Sdl.PInvoke.SetHint("SDL_VULKAN_LIBRARY", "/opt/homebrew/lib/libvulkan.1.dylib"); // or Sdl.Constants.HintVulkanLibrary -
For validation layers with Homebrew's
dotnet, run the real binary. Homebrew'sdotnetis a shell script, and macOS stripsDYLD_*variables when it runs one. The layer's library then isn't found, andvkCreateInstancefails withVK_ERROR_LAYER_NOT_PRESENT. Launch the binary directly instead:export DOTNET_ROOT=/opt/homebrew/opt/dotnet/libexec export DYLD_FALLBACK_LIBRARY_PATH=/opt/homebrew/lib "$DOTNET_ROOT/dotnet" bin/Release/net10.0/YourGame.dllThis comes from how Homebrew packages .NET, not from BRUTAL.
MoltenVK 1.4.2 on an M1 Max reports Vulkan 1.4, with dynamic rendering, synchronization2, scalar block layout, descriptor indexing, buffer device address and timestamps. No geometry shaders.
MoltenVK: copies cost per region
Measured on an M1 Max: vkCmdCopyBufferToImage cost about 1 µs of GPU time per region, and 8,192 small regions took about 9 ms. Merging neighbouring regions into fewer, bigger copies keeps the count down. Native drivers may be cheaper per region; that's not measured here.
SDL3 with Vulkan
BRUTAL's Vulkan demo uses GLFW for its window. The equivalent calls with SDL3:
if (!Sdl.Init.InitSubSystem(SdlInitFlags.Video | SdlInitFlags.Gamepad | SdlInitFlags.Events))
throw SdlException.FromError("SDL init"); // returns false on failure; it doesn't throw
var window = Sdl.Video.CreateWindow("My Game", 1280, 720,
SdlWindowFlags.Vulkan | SdlWindowFlags.Resizable | SdlWindowFlags.HighPixelDensity);
string[] extensions = Sdl.Vulkan.GetInstanceExtensions(); // for the instance
nint surface = Sdl.Vulkan.CreateSurface(window, instanceHandle); // after the instance
while (Sdl.Events.PollEvent(out var ev))
{
switch (ev.type)
{
case SdlEventType.Quit: /* … */ break;
// fields follow C's lowercase names: ev.key.scancode, ev.motion.xrel, ev.gdevice.which
}
}- High-DPI screens: with
HighPixelDensity, mouse coordinates are in window coordinates (points, on macOS). Multiply bySdl.Video.GetWindowPixelDensity(window)for pixels, and size the swapchain fromSdl.Video.GetWindowSizeInPixels. - Gamepads:
Sdl.Gamepad.GetGamepads(),OpenGamepad(id),GetGamepadAxis(ashort; triggers run 0 to 32767),GetGamepadButton,RumbleGamepad. Hotplugging arrives asGamepadAddedandGamepadRemovedevents. - Errors: there's no
Sdl.GetError.Sdl.PInvoke.GetError()returns the text, andSdlException.FromError("what failed")wraps it in an exception with your context. - Hints:
Sdl.PInvoke.SetHint(name, value).
ShaderC
using Brutal.ShaderCApi;
using var compiler = ShaderC.CreateCompiler();
using var options = ShaderC.CreateCompilerOptions();
options.SetTargetEnv(TargetEnv.Vulkan, (uint)EnvVersion.Vulkan_1_3);
using var result = compiler.Compile(source, ShaderKind.ComputeShader, "blur.comp", "main", options);- Failures throw
ShaderExceptionsubclasses carrying shaderc's message. Dispose theCompilationResult. - Nothing but comments and blank lines may come before
#version. If you expand#includes yourself and add#linemarkers, put the root file's marker after#version. - Includes:
options.SetDefaultIncludeCallbacks()exists. Expanding includes yourself instead tells hot reload every file a shader depends on.
Dear ImGui on Vulkan, with SDL3
The official ImGui samples run on GLFW with OpenGL. On a Vulkan renderer with SDL3, the pieces are these.
The renderer
Brutal.ImGuiApi.Backends.VulkanBackend takes a DeviceEx, the graphics queue and a classic VkRenderPass. In 2026.10.3 it has no dynamic-rendering path, so a renderer built on dynamic rendering needs one small render pass that draws the UI over the swapchain image. The backend handles ImGui 1.92's texture uploads itself.
var backend = new VulkanBackend(new VulkanBackend.CreateInfo
{
Device = device, // the DeviceEx from CreateDevice
GraphicsQueue = queue,
RenderPass = uiRenderPass,
MinImageCount = 2,
ImageCount = swapchainImageCount,
SampleCount = VkSampleCountFlags._1Bit,
DescriptorPoolSize = 64,
});The platform side
The platform backend that ships is GLFW's. With SDL3 you feed ImGui yourself: io.AddMousePosEvent, AddMouseButtonEvent, AddMouseWheelEvent, AddKeyEvent (with ImGuiKey.Mod_Ctrl and friends for modifiers) and AddFocusEvent, and set DisplaySize, DisplayFramebufferScale and DeltaTime every frame. It comes to roughly 200 lines.
The string arena: reset it every frame
ImGui calls take an ImString. A "..."u8 literal is passed through as is, but a string, a $"..." or a char span is copied into ImString.SharedStorage, a fixed-size arena (8 MiB) meant to last one frame. Resetting it is up to you; the official ImGui samples point this out in a comment. Without the reset the arena fills, and the app dies with ArgumentOutOfRangeException: Cannot grow beyond MaxCapacity from Brutal.Strings: after a few minutes in a text-heavy debug UI.
Reset it once a frame. The official samples do it right after ImGui.NewFrame():
ImGui.NewFrame();
ImString.SharedStorage.Reset();Small things
- Setup:
ImGui.CreateContext(), thenImGui.GetIO(). Setio.IniFilename = defaultto stop it writingimgui.ini. - Types: vectors are
Brutal.Numerics.float2andfloat4.Combo's string overload takes its items separated by\0and ending in\0\0.
What BRUTAL leaves to you
BRUTAL is deliberately small, and these are the application's to write for a setup like the one above:
- SDL3 input for ImGui, roughly 200 lines feeding
ImGuiIO(above). - A classic render pass for the ImGui Vulkan backend, next to a renderer that otherwise uses dynamic rendering.
#includeexpansion for ShaderC, if hot reload needs to know every file a shader uses.- A Vulkan loader on macOS.
For changes to BRUTAL itself, its CONTRIBUTING.md asks you to open an issue, and wait for a maintainer to agree the approach, before anything bigger than a small fix. Libraries built on top of BRUTAL belong in its Tools and Libraries forum.
The licence in brief
BRUTAL is free, including for commercial games. On top of the usual MIT-style notice, its licence asks three things of a product distributed to end users: the BRUTAL copyright notice in your credits; the BRUTAL logo (from ahwoo.com/brutal-branding) before gameplay starts, unaltered, legible and no less prominent than other parties' logos; and NOTICE.txt shipped alongside. Third-party parts like SDL, shaderc and Dear ImGui keep their own licences, listed in each package's LICENSES folder. Ahwoo offers a logo waiver in some cases and a separate commercial licence; see the Licence section of BRUTAL's README.
This summary isn't legal advice: read LICENSE.txt for the actual terms.