Shared Render Code | Fomenko

fomenko.fr · 2026-10-05 · by Long Nguyen ·generic
Drawing stuff on screen is a tedious affair in general, and even more when we are dealing with multiple platforms. Each platform has its own graphics …

Shared Render Code | Fomenko

Shared Render Code

By Alexandre Chêne|September 29, 2026

Drawing stuff on screen is a tedious affair in general, and even more when we are dealing with multiple platforms. Each platform has its own graphics API, with its own shader language, forcing us to duplicate the render code.

What many do, is to build a common interface. Then the render code is using only this interface to do graphics stuff. Each platform implements the interface with whatever graphics API they want, like Metal, DirectX, Vulkan, and such. Many engines are doing exactly that, like Unreal with their “RHI”, making code way more portable.

And this article explains my own “common” graphics interface (written in Jai), which is currently used for my personal projects. Currently two backends are implemented, Metal on Apple devices, and DirectX 11 on Windows.

The abstraction layer powers the game I'm currently working on.

The Interface

I want to write render code as simple and direct as possible, without tons of boilerplate and things to fill in upfront. In fact, I wish to have an "immediate mode" feeling, where I can easily mix logic and render code.

The latest graphics APIs are designed around fixed pipeline objects, letting us being fully in charge of creating those objects. A pipeline object takes a few milliseconds to hundreds of milliseconds to create, so it is costly, but because it is fully under our control, we can decide when it happens; we can create them all at judicious moment, like when the program boots.

But the main cost for that though, is rigidity in the render code; you have to declare all state permutations in advance. So I've to fill up a new pipeline descriptor tax form if for whatever reason I want to change the topology (to show wireframe), or activate MSAA in game settings, change the winding order for some gameplay mechanics, deactivate depth testing for the gizmo... it is infinite and very tedious.

Older graphics APIs (notably OpenGL), are designed differently. There's one global context and the render code mutates it. The render code doesn't care about pipeline creation; at draw time, the API will create and cache the pipeline for you. But because the context is global, it leaks through every "scope" you may have in your render code. That was a giant pain with OpenGL for example, if you forget to bind/unbind something, you'll get punish like in a Souls game.

Still, I made this abstraction code around that idea; there’s one big “state object” which the render code mutates with setter functions, and the state is scoped to a render pass, as each render pass resets the render state. That way, there are no leaks happening.

As I said earlier, the abstraction code sits currently on top of two implementations:

  • On Metal, I now lazily creates pipelines at the very last moment (when the render code calls `draw`). Then, we need to create a PSO (Pipeline State Object) and a Depth/Stencil state. Those states take time to create, so we hash the fields describing the PSO from the render state, then we cache the result.
  • On DirectX, it's simpler, Rasterizer, Blend and Depth/Stencil states are already cached by the D3D11 runtime, so we just cache the vertex layout ourselves.

Now, hashing happens at each draw call, so it is not free as well. So I check if the render state changed between draw calls; if it did not, we use the previous pipeline(s), if it did we do the hash + lookup machinery. We can probably still do a bit better, but it’s good enough for now!

Here's how the "global" render state looks like:

Render_State :: struct {
    vertex_shader       : *GFX_Shader;
    pixel_shader        : *GFX_Shader;

scissor_enabled : bool; scissor_rect : Recti;

topology := GFX_Topology_Primitive_Type.TRIANGLE; fill_mode := GFX_Fill_Mode.FILL; cull_face := GFX_Polygon_Face.BACK; winding_order := GFX_Polygon_Winding_Order.CCW;

sample_count : u32 = 1;

color_formats : [GFX_MAX_COLOR_ATTACHMENTS] GFX_Pixel_Format; blend_modes : [GFX_MAX_COLOR_ATTACHMENTS] GFX_Blend_Mode;

depth_enabled : bool; depth_write : bool; depth_comp : GFX_Compare_Func;

stencil_enabled : bool; stencil_front : GFX_Stencil_Face_State; stencil_back : GFX_Stencil_Face_State; stencil_read_mask : u8 = 0xff; // All bits set. stencil_write_mask : u8 = 0xff; stencil_ref : u32; depth_stencil_format: GFX_Pixel_Format;

// Backed at compile time. vertex_layout : [] GFX_Vertex_Input_Desc; vertex_layout_hash : u64; } #no_padding; // We use memcmp, that's why we have #no_padding.

Because the abstraction code creates pipelines lazily, at the last moment, pipeline creation is out of our hands now, which means we cannot fully predict when it happens. Games end up with big freezes when the program encounters a new permutation in the pipeline state object, and as players, you don’t want that.

There are many ways to mitigate that. You can record PSO creation during some play sessions, and incorporate this record when making a release build. Then the game could create all the PSOs at once while booting. There are other solutions as well, and you can read more here or there. On Metal, you could use MTLBinaryArchive.

Another issue due to having a single "global" render state; it could be pretty difficult to paralelize the render code. But right now, it's not a problem for me because DirectX 11's underlying state is not thread safe, so I don't even try to do such things. But later on, I will care, and I think I could simply store the render state alongside a command buffer.

Using Slang as an intermediary shader language

Different graphics APIs use different shader languages too. Metal uses its own shader language, and DirectX uses “High-Level Shader Language” (HLSL). So even if we manage to get a common API to write render code, we still have to write shaders twice.

What people tend to do here, is to write shaders in an intermediary language which would then transpile to specific Metal/HLSL shader code. I decided to use Slang, a shader language and compiler, which does exactly that. And because shaders are parsed and transpiled by Slang, it allows you to get type reflection on them, which is very useful to remove mismatch between render code and shader parameters (slot mismatch, etc).

Though, one of the main costs is stability. Generated shaders will be transpiled to your chosen graphics shader language, and then compiled by Metal or DirectX; so at the bottom it is compiled by different compilers. Each of those compilers has their own set of optimization or strictness rules; and a compiler could decide or not to unroll a loop, or is more or less strict around unused bind slots, etc; and those differences could create surprises from one backend to another. I still find it worth the cost, but you have to keep that in mind.

Another cost is readability. Generated shaders are always more tedious to debug, names can be changed, code blocks move. But I think in debug mode, Slang is doing an okay job.

API Walkthrough

The first thing we want to do, is asking the operating system to give us a graphics context. You do it by providing your program a window, like so `create_context(*my_window);`.

Internally, I do all the necessary code to create a device, a swap chain and so forth. On Windows, there’s some code to find and use the most powerful graphics card found in your machine as well.

Here’s what it looks like, using SDL to create the underlying window:

window := SDL_CreateWindow("Oleg", SDL_WINDOWPOS_UNDEFINED, SDL_WINDOWPOS_UNDEFINED, 800, 600, flags);

// HWND on Windows, NSWindow on macOS. platform_window: *void;

winfo : SDL_SysWMinfo; SDL_GetWindowWMInfo(window, *winfo);

#if OS == { case .MACOS; platform_window = winfo.info.cocoa.window; case .WINDOWS; platform_window = winfo.info.win.window; } // Once you've got the real window pointer, we pass it to our function. if !create_context(xx platform_window) { exit(1); }

Now that the context is created, we can talk to the GPU and draw things on screen.

But first, let’s add some shaders. For that, you need a shader written in Slang, and you add it by calling `add_shader` with your shader file path as parameter, then the lib will open it, read it, transpile it to the corresponding shader language and finally compile it! You can as well pass a buffer to `add_shader` instead of the file path (particularly useful if you want to pack shaders in the executable for example).

vertex_shader, pixel_shader := add_shader("src/render/shaders/water_shading.slang");

// You can refer to this shader with its name (name of the shader file) later on as well. // Like `set_pixel_shader("water_shading");`.

To render stuff, we first create a render pass. It’s done simply by calling `begin_render_pass`. The GPU needs some information about our pass, like the target texture to write into, if we clean this texture with some color or not, do we have a depth buffer? And so on and so forth.

Here’s a concrete example, from my current game’s gbuffer pass. Like that, I can show you that we can have multiple render targets as well.

set_color_attachment(*desc, 0, *position_texture);
clear_color(*desc, 0, BLACK);
set_color_attachment(*desc, 1, *normal_texture);
clear_color(*desc, 1, BLACK);
set_color_attachment(*desc, 2, *entity_ids_texture);
clear_color(*desc, 2, .{ -1, 0, 0, 0 });

set_depth_attachment(*desc, *main_scene_depth_stencil_texture); set_stencil_attachment(*desc, *main_scene_depth_stencil_texture);

pass := begin_render_pass("Gbuffer Pass", desc); defer end_render_pass(*pass);

// Our render pass setters. // set_vertex_shader(...); // set_buffer(...); // set_texture(...);

Alright, now that we created a render pass, we can actually draw some stuff! But wait, for that we need to know which shader we want to use, and fill up some vertices as well, set some texture perhaps... how do we do that!?

All this information is stored in a render state, which we’re going to mutate. You can fully reset this state with `reset_render_state()` as well, but generally you don't need because `begin_render_pass` is doing it for you.

We also infer the color/depth formats and the sample count from the textures attached to the render pass.

// Inside a render pass.

// Let's set my shaders. set_vertex_shader(vertex_shader); set_pixel_shader(pixel_shader);

Vert_Params :: struct { frame_info : GFX_Pipeline_Binding; };

Pixel_Params :: struct { frame_info : GFX_Pipeline_Binding; default_sampler : GFX_Pipeline_Binding; immediate_texture : GFX_Pipeline_Binding; };

// You get back the proper slot information here. Also, you will get an error // if you bind something with a name that doesn't match the one in your shader. vert_params, pixel_params := get_bindings(Vert_Params, Pixel_Params);

set_buffer(*pass, vert_params.frame_info, *frame_info_buffer, 0, size_of(Frame_Info)); set_buffer(*pass, pixel_params.frame_info, *frame_info_buffer, 0, size_of(Frame_Info));

// If the texture is null, you can also fall back to another default texture. set_texture(*pass, pixel_params.immediate_texture, my_texture, fallback = *white_texture); set_sampler_state(*pass, pixel_params.default_sampler, *default_sampler);

// For a generic buffer. set_buffer(*pass, shader_binding, *my_buffer, some_offset_if_any, size_of_my_buffer);

// For a vertex buffer (same API tho). set_vertex_buffer(*pass, VERTEX_SLOT_INDEX, *vertex_buffer, some_offset_if_any, size_of_vertex_buffer);

// The viewport, where you can pass an array of viewports as well. set_viewport(*pass, .{ width = 800, height = 600 });

// The list of things you can set is not exhaustive here, there's more.

Alright, once everything is set, you can finally draw.

// If your vertices have indices, you do it with `draw_indexed`.
draw_indexed(*pass, *index_buffer, index_count, .UINT32, instance_count);
// If not, simply with `draw`.
draw(*pass, vertex_count, instance_count);

Once we are done with our render passes, we can simply call `present()` which will show the back buffer on screen.

If you want to see full examples, there’s a “hello triangle” and a GUI system available.

---

Right now, I’m using this abstraction for my current in-development game. Many things are built on top of that and I’m planning to integrate some in this module as well. For example, adding some immediate draw functions like draw\_line, draw\_cube could be interesting, or adding a small gpu allocator.

Though, the next big work would be to move from DirectX 11 to DirectX 12. It maps closer to the Metal API, and it would simplify the few internal “hacks” I’ve done. But it would add new concepts like fences, and it takes a long time to implement... So I’ll see!

My abstraction module is public here. I do believe it serves well as being a source of information and knowledge for those who want to make their own.

–––

Don't hesitate to reach out on bluesky or via twitter.

fomenko.fr · 1 videos · 12 links

Comments (0)

No comments yet