Warning: This project is still in development, so expect some breaking changes.
It isn't controversial to say this: graphics APIs are a mess. "Modern" graphics APIs - which are a decade old at this point - all present numerous concepts that are completely useless on today's hardware. They are extremely bloated, often adding new extensions to cover for past missteps in the design. It can all be massively simplified.
no_gfx's goal is to implement an ideal "API of the future" on top of existing APIs (Vulkan), greatly simplifying graphics programming without sacrificing modern features like indirect rendering and raytracing. It initially started as a 1:1 recreation of the theoretical API outlined in Sebastian Aaltonen's "No Graphics API" blog post; there are now a few divergences - partly due to the limitations of current APIs - but the overall design and core philosophy is still the same.
Third-party binaries are already included, so it's sufficient to copy the gpu directory and add import "gpu" to your files.
The API is straightforward to use:
// --- Initialization
ok := gpu.init()
ensure(ok)
defer gpu.cleanup()
gpu.swapchain_init(/* surface */, Frames_In_Flight)
// --- Create shaders
vert_shader := gpu.shader_create(/* spirv_binary */, .Vertex)
frag_shader := gpu.shader_create(/* spirv_binary */, .Fragment)
defer {
gpu.shader_destroy(vert_shader)
gpu.shader_destroy(frag_shader)
}
// --- Create arenas and allocate memory
arena := gpu.arena_init()
defer gpu.arena_destroy(&arena)
verts := gpu.arena_alloc(&arena, Vertex, 3)
// verts.cpu[0].pos = ...
indices := gpu.arena_alloc(&arena, u32, 3)
// indices.cpu[0] = ...
verts_local := gpu.mem_alloc(Vertex, 3, gpu.Memory.GPU)
indices_local := gpu.mem_alloc(u32, 3, gpu.Memory.GPU)
defer {
gpu.mem_free(verts_local)
gpu.mem_free(indices_local)
}
// --- Issue copy commands to GPU local memory
upload_cmd_buf := gpu.commands_begin(.Main)
gpu.cmd_mem_copy(upload_cmd_buf, verts_local, verts, len(verts.cpu))
// ...
gpu.cmd_barrier(upload_cmd_buf, .Transfer, .All, {})
gpu.queue_submit(.Main, { upload_cmd_buf })
// --- Frame resources
frame_arenas: [Frames_In_Flight]gpu.Arena
for &frame_arena in frame_arenas do frame_arena = gpu.arena_init()
defer {
for &frame_arena in frame_arenas do gpu.arena_destroy(&frame_arena)
}
next_frame := u64(1)
frame_sem := gpu.semaphore_create(0)
defer gpu.semaphore_destroy(frame_sem)
for true
{
proceed := handle_window_events(window)
if !proceed do break
if next_frame > Frames_In_Flight {
gpu.semaphore_wait(frame_sem, next_frame - Frames_In_Flight)
}
swapchain := gpu.swapchain_acquire_next() // Blocks CPU until at least one frame is available.
frame_arena := &frame_arenas[next_frame % Frames_In_Flight]
gpu.arena_free_all(frame_arena)
// --- Render frame
cmd_buf := gpu.commands_begin(.Main)
gpu.cmd_begin_render_pass(cmd_buf, {
color_attachments = {
{ texture = swapchain, clear_color = { 1.0, 0.0, 0.0, 1.0 } }
// Other optional settings...
}
})
gpu.cmd_set_shaders(cmd_buf, vert_shader, frag_shader)
Vert_Data :: struct {
verts: rawptr,
// Uniforms...
}
verts_data := gpu.arena_alloc(frame_arena, Vert_Data)
verts_data.cpu.verts = verts_local.gpu.ptr
// Just pass pointers to your data!
gpu.cmd_draw_indexed_instanced(cmd_buf, verts_data, {}, indices_local, 3, 1)
gpu.cmd_end_render_pass(cmd_buf)
gpu.queue_submit(.Main, { cmd_buf }, frame_sem, next_frame)
gpu.swapchain_present(.Main, frame_sem, next_frame)
next_frame += 1
}
gpu.wait_idle() // Wait until the end of execution for resource destructionThere are many examples you can find in the examples directory.
Like most things in life, this is not without its tradeoffs:
- It assumes relatively recent hardware. It requires Vulkan 1.3 with the following extensions: VK_EXT_shader_object, VK_EXT_descriptor_buffer, VK_KHR_draw_indirect_count. It can use more extensions for optional features such as raytracing.
- Shader arguments are all passed via a single pointer. This is very flexible and easy to work with, but it can also prevent some prefetching/optimizations that drivers usually implement with standard bindings and vertex buffers. This will probably make shaders in general slightly slower. How much impact this will have, I can't say for sure right now. On the other hand, working with a nicer and better API can make optimization easier and quicker.
- If you're trying to debug the examples using RenderDoc, and you can't, that's because debugging of descriptor buffers is simply broken on AMD Windows due to a driver bug, and this project uses them. The bug has been reported on July 2025, so you can either switch to an NVidia card or annoy AMD if you want this fixed (half joking).
I think people should be able to use whichever shading language they want, but there are a few limitations due to the nature of this project. no_gfx uses pointers as the main way to pass data to shaders, so shading languages that don't support pointers at all are sadly disqualified - this includes HLSL. Other than that, any shading language can be used as long as a .spirv binary is produced with the following format (pseudocode, GLSL-like):
layout(set = 0, binding = 0) uniform texture2D textures[];
layout(set = 1, binding = 0) uniform image2D textures_rw[];
layout(set = 2, binding = 0) uniform sampler samplers[];
layout(set = 3, binding = 0) uniform accelerationStructureEXT bvhs[]; // Optional, for raytracing.
// For vertex and fragment shaders:
layout(push_constant) uniform Push
{
void* vert_data;
void* frag_data;
void* indirect_data;
};
// For compute shaders:
layout(push_constant) uniform Push
{
void* compute_data
};All examples provide Slang variants of their shaders so you can get an idea of how to use an existing shading language with no_gfx.
With that said - much like graphics APIs, shading languages also carry a lot of historical baggage and cruft. For this reason, I think it's valuable to work on a shading language that is tailor-made to these assumptions and that doesn't require any boilerplate. Here's a small sample of nosl:
Vertex :: struct
{
pos: vec3,
color: vec3
}
Data :: struct
{
verts: []Vertex,
}
Output :: struct
{
pos: vec4 @position,
color: vec4 @out_loc(0),
}
main :: (vert_id: uint @vert_id, data: ^Data @data) -> Output
{
out: Output;
out.pos = vec4(data.verts[vert_id].pos.xyz, 1.0);
out.color = vec4(data.verts[vert_id].color, 1.0);
return out;
}Importing no_gfx into your own project mostly just involves copying the gpu/ directory, but to build this project you will need:
Binaries for dependencies are included.
See the Makefile for all available commands.
Run make to build all examples into the build directory.
Run make example1, make example2, etc. to run individual examples.
Feel free to contact me on discord for any questions.