Designing A Shader Language For My Engine
Over the past six months, I took a break from writing because I simply couldn’t find a way to make it fit into my personal life, which had become unusually busy. Even so, I have been able to make time to tinker with some game-adjacent projects. As life is thankfully calming down a bit, I’m realizing that I’ve accumulated a healthy backlog of posts that I’m excited to start working through. To start off, I’m beginning a series on the design and implementation of a custom shader language, which I’m calling “slim” for now.
If you’re writing your own compiler or just want to see some code, you can find my implementation here. Keep reading if you are more interested in the though process behind the design of the language itself!
Background
My engine, which I have not yet named, can import a 3D scene and render it to a window, end to end. But, there’s no way for a user to control how scenes are rendered - scene data is processed by a bare-bones, compiled-in shader that knows only how to sample textures and apply basic lighting. And in the land of games, where visual art style is such a massive part of a game’s identity and player experience, this doesn’t work. My engine needs a proper material system.
The word “material” conjures mental images of graph-like builder UIs, physically realistic surfaces, and stylized character art. But, at its core, what is a material? My definition: A set of instructions and metadata that dictate how a mesh is rendered. In other words, from the perspective of a game engine, a material is the combination of a shader and its inputs. From there, the requirements for a minimal material system practically write themselves: users need a way to 1) write shaders and 2) manage their inputs.
My vision is that developers can create materials by writing shaders with self-describing inputs, which can then be configured in the engine UI and assigned to a mesh. Built-in materials, like PBR, could be implemented as default shaders provided by the engine itself. And, because materials are implemented in code, users would be able to hack and share them like any other open-source software.
Design (Really, A Rough Sketch)
What should shaders look like? To abstract the underlying graphics API, expose engine-specific features in code, and streamline the process of writing a shader, providing a custom shader language makes a lot of sense. To me, a good shader language feels like this:
- Inputs are self-descriptive and strongly typed, and can be annotated to help the engine understand their semantics.
- The syntax is familiar to anyone who knows GLSL or HLSL.
- No magic! A shader’s behavior at runtime should be obvious and predictable based on its code.
- The language is minimal, and features are introduced carefully at key points to hide complexity and engine internals.
- Shaders are reusable and flexible. A single shader can support a broad set of features, but only pay for the performance that it needs at runtime. If a feature isn’t used, it can be compiled away.
- Shaders can be pre-compiled, and compiler outputs specific to a target graphics API can be shipped directly with the game.
Here’s an example how such a shader could look:
// Inputs are strongly typed and can be annotated using tags. Tags have
// semantic meaning to the engine - in this case, the tag is telling the engine
// to display a colorpicker in the configuration UI.
#colorpicker
property vec3 color = vec3(1.0, 1.0, 1.0);
// The feature keyword can be used to group a set of inputs related to a
// specific feature. Code related to a specific feature can be wrapped in a
// require block, which will be compiled out if the feature isn't enabled.
feature texture {
// Tags can accept metadata as well. In this case, the #display tag tells the
// engine to use the name "Texture" instead of "Tex" in the configuration UI.
#display "Texture"
property sampler2D tex;
}
// Shared variables can be used to pass information between shader blocks. For
// example, the vertex shader can write to a shared variable, and the value can
// be read from the fragment shader.
shared vec3 xyz;
shared vec2 uv;
shared vec3 normal;
// Shader blocks define the behavior of each shader type. They are essentially
// just functions that must return a specific type related to their function.
shader vertex {
// Useful global variables and functions are provided by the engine
normal = LOCAL_TO_WORLD_MX * vec4(V_NORMAL, 0.0);
xyz = LOCAL_TO_WORLD_MX * vec4(V_NORMAL, 1.0);
uv = V_UV;
return LOCAL_TO_CLIP_MX * vec4(V_XYZ, 1.0);
}
shader fragment {
// If the texture feature is not enabled, this code will be compiled out.
require texture {
color = color * texture(tex, uv);
}
return color;
}
There is already plenty of material online about how to implement a compiler, so I’m planning to make this series an anthology of interesting problems and design decisions, not a comprehensive guide on compilers. You can read the other posts in the series here: