Shared Render Code
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 Interface
I want to write render code as simple and direct as possible, without tons of boilerplate and things to fill up. Also, I like the “immediate” feeling of some older graphics APIs for example, where a bunch of functions mutates an internal state; it goes well with the procedural render code I’m making in general. I just want control over how I can “scope” this state, like having one state per render pass for example.
And I made this abstraction code around that idea. There’s one big “state object” which the render code mutates with setter functions, and each state is scoped to a render pass (as each render pass, resets the render state). Here’s what the State object looks like, with all describing fields:
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;
vertex_layout : [] GFX_Vertex_Input_Desc;
// This is used by Metal only, we "hash" the vertex layout at comptime.
vertex_layout_hash : u64;
} #no_padding; // We use memcmp, that's why we have #no_padding.In Metal, 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, 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 costly as well. So I check if the render state changed between draw calls; if it does not, we use the previous state, if it does we do the hash + lookup machinery. We can probably still do a bit better, but it’s good enough for now!
One big tradeoff and a broader problem with state creation with graphics APIs; creating a PSO takes a few milliseconds to hundreds of milliseconds, so it is costly. And if those creations are out of your hands, it also means you cannot fully predict when they happen. 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.
Right now, my abstraction creates them lazily, and I don't do anything about the problem yet. I'll see what I can do once it bites me for real, but there are many ways to mitigate that. One idea is to create all necessary PSOs while the player doesn’t do anything, like a load screen. You can also 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.
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 a library called Slang 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, because it’s relatively rare but you have to keep that in mind.
Another smaller cost is readability. Generated shaders are always a bit more tedious to debug. But I think in debug mode, Slang is doing an okay job, so I’m pretty happy with it for now.
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.