Brutally Honest

Unofficial documentation: notes on BRUTAL from building Kiln so far.

  • Unofficial
  • Tested on an M1 Max
  • Checked November 7, 2026
C to C#, at a glance
CvkCreateBuffer(device, &info, NULL, &buffer)
BRUTALdevice.CreateBuffer(info, null)
CvkQueueSubmit2(queue, 1, &submit, fence)
BRUTALqueue.Submit2([submit], fence)
CVK_FORMAT_R8G8B8A8_SRGB
BRUTALVkFormat.R8G8B8A8SRGB
The whole table, and its exceptions

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.cs

On 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

  1. Add using Brutal.VulkanApi.Abstractions;Many everyday helpers (CreateFence, WaitForFence, AllocateCommandBuffer, typed PushConstants) are extension methods in that namespace. Without the using, they look like they don't exist.
  2. Using Dear ImGui? Reset ImString.SharedStorage once a frameOr the app eventually crashes. Details.
  3. To find a Vulkan call, search for its C nameLook for vkCmdBlitImage, say, in Brutal.Vulkan/Generated/Extensions/. The XML docs there carry the Vulkan spec text. Queue calls like Submit2 and PresentKHR are on the Queue class, in Brutal.Vulkan/Manual/Classes/Queue.cs.
  4. On a Mac, install a Vulkan loader yourselfHow.
  5. Pass spans, not arraysCommand wrappers take ReadOnlySpan<T>, so hand them CollectionsMarshal.AsSpan(list). A per-frame ToArray() of a few thousand copy regions (56 bytes each) lands on the large object heap and can trigger gen-2 garbage collections.

The packages

PackageUsed forNamespace
Ahwoo.BRUTAL.VulkanVulkan bindingsBrutal.VulkanApi
Ahwoo.BRUTAL.Vulkan.AbstractionsInstance and device setup, fences, command buffers, push constantsBrutal.VulkanApi.Abstractions
Ahwoo.BRUTAL.SdlWindow, events, gamepads, the Vulkan surface (SDL3)Brutal.SdlApi
Ahwoo.BRUTAL.ShaderCGLSL to SPIR-V at runtimeBrutal.ShaderCApi
Ahwoo.BRUTAL.ImGui, .ImGui.BackendsDear 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, float4Brutal, 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 CIn BRUTAL
vkCreateBuffer(device, &info, NULL, &buffer)var buffer = device.CreateBuffer(info, null);
info.sType = VK_STRUCTURE_TYPE_…Filled in for you
info.pNext = &nextinfo.Next = &next
colorAttachmentCount + pColorAttachmentsColorAttachmentCount + 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 twiceGetSwapchainImagesKHR_Count(…), then GetSwapchainImagesKHR_Write(…, span)
vkMapMemorydevice.MapMemory(new MapInfo(memory)) returns a Ptr; MapMemory<T> returns a Ptr<T>

Names and types

In CIn BRUTAL
VK_FORMAT_R8G8B8A8_SRGBVkFormat.R8G8B8A8SRGB
VK_FORMAT_B8G8R8A8_UNORMVkFormat.B8G8R8A8UNorm
VK_IMAGE_TYPE_2DVkImageType._2D
VK_SAMPLE_COUNT_1_BITVkSampleCountFlags._1Bit
VkBool32Bool32 (true and false convert)
VkDeviceSizeByteSize64, e.g. new ByteSize64(8); a ByteSize converts to it implicitly
A timeout of UINT64_MAXnint.MaxValue (the parameter is a signed nint: still about 292 years in nanoseconds)

A few exceptions

  • An out-of-date swapchain throws. AcquireNextImageKHR and PresentKHR return a VkResult for results that aren't errors (SuboptimalKHR, and for Acquire Timeout and NotReady), and Acquire hands back the image index as an out parameter. ErrorOutOfDateKHR is an error, so it throws like the rest: catch it with catch (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, not ByteSize64.
  • Fixed-size arrays index directly, both inline arrays (region.SrcOffsets[1] = …) and vector fields (clear.Float32 is a float4, so clear.Float32[0] = …). Name strings like a device's DeviceName are C# fixed buffers: copy the properties struct from GetProperties() to a local first.
  • A few calls take a pointer where you might expect in, like cmd.ClearColorImage(image, layout, &color, ranges).
  • When an enum name doesn't compile, search Brutal.Vulkan/Generated/Enums for 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 (in VkPhysicalDeviceFeatures), if color attachments in one pipeline have different blend states or write masks.
  • ShaderDemoteToHelperInvocation (in VkPhysicalDeviceVulkan13Features), if your GLSL uses discard and you compile for Vulkan 1.3 with ShaderC. discard then compiles to OpDemoteToHelperInvocation, 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:

  1. 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 needs libvulkan.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
  2. 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 CreateWindow fails with "Failed to load Vulkan Portability library".

    Sdl.PInvoke.SetHint("SDL_VULKAN_LIBRARY", "/opt/homebrew/lib/libvulkan.1.dylib");   // or Sdl.Constants.HintVulkanLibrary
  3. For validation layers with Homebrew's dotnet, run the real binary. Homebrew's dotnet is a shell script, and macOS strips DYLD_* variables when it runs one. The layer's library then isn't found, and vkCreateInstance fails with VK_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.dll

    This 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 by Sdl.Video.GetWindowPixelDensity(window) for pixels, and size the swapchain from Sdl.Video.GetWindowSizeInPixels.
  • Gamepads: Sdl.Gamepad.GetGamepads(), OpenGamepad(id), GetGamepadAxis (a short; triggers run 0 to 32767), GetGamepadButton, RumbleGamepad. Hotplugging arrives as GamepadAdded and GamepadRemoved events.
  • Errors: there's no Sdl.GetError. Sdl.PInvoke.GetError() returns the text, and SdlException.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 ShaderException subclasses carrying shaderc's message. Dispose the CompilationResult.
  • Nothing but comments and blank lines may come before #version. If you expand #includes yourself and add #line markers, 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(), then ImGui.GetIO(). Set io.IniFilename = default to stop it writing imgui.ini.
  • Types: vectors are Brutal.Numerics.float2 and float4. Combo's string overload takes its items separated by \0 and 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.
  • #include expansion 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.