I recently played around with shader code generation and the GLSL reference compiler in Oryol.
The result is IMHO pretty neat:
Shader source files (*.shd) live in the IDE next to C++ files:
Shader files are written in normal GLSL syntax with custom annotations (those @ and $ tags):
When compiling the project, a custom build step will generate vertex- and fragment shaders for different GLSL versions and run them through the GLSL reference compiler. Any errors from the reference compiler are converted to a format which can be parsed by the IDE:
Error parsing also works in Visual Studio:
Unfortunately I couldn’t get error parsing to work in QtCreator on Linux. The error messages are recognised, but double-clicking them doesn’t work.
After the GLSL compiler pass, a C++ header/source file pair will be created which contains the GLSL shader code and some C++ glue to make the shader accessible from the engine side.
The edit-compile-test cycle is only one or two seconds, depending on the link time of the demo code. Also, since the shader generation runs as a normal build step, shader code will also be generated and validated in command line builds.
Here’s how it works:
When cmake runs to create the build files it will look for XML files in the source code directories. For each XML file, a custom build target will be created which invokes a python script. This ‘generator script’ will generate a C++ header/source pair during compilation.
This generic code generation has only been used so far for the Oryol Messaging system, but it is flexible enough to cover other code generation scenarios (like generating shader code).
Setting up the custom build target involves 3 steps:
The actual build target must be created, cmake has the add_custom_target macro for this:
add_custom_target(${target}_gen
COMMAND ${PYTHON}
${ORYOL_ROOT_DIR}/generators/generator.py
${xmlFiles}
COMMENT "Generating sources...")
This statement takes a variable target with the name of the build target which will compile the generated C++ sources plus a xmlFiles list variable and it will generate a new build target called [target]_gen The variables PYTHON and ORYOL_ROOT_DIR are config variables pointing to the python executable and the Oryol root directory.
To get the right build order, a target dependency must be defined so that the generated target is always run before the build target which needs the generated C++ source code:
add_dependencies(${target} ${target}_gen)
Finally we need to resolve a chicken-egg situation. All C++ files must exist when cmake assembles the build files, but the generated C++ files will only be created during the first build. To fix this situation, empty placeholder files are created if the generated sources don’t exist yet:
foreach(xmlFile ${xmlFiles})
string(REPLACE .xml .cc src ${xmlFile})
string(REPLACE .xml .h hdr ${xmlFile})
if (NOT EXISTS ${src})
file(WRITE ${src} " ")
endif()
if (NOT EXISTS ${hdr})
file(WRITE ${hdr} " ")
endif()
endforeach()
These 3 steps take care of the build configuration via cmake.
On to the python generator script:
First, the generator script parses the XML ‘source file’ which caused its invokation. For the shader generator, the XML file is very simple:
<Generator type="ShaderLibrary" name="Shaders" >
<AddDir path="shd"/>
</Generator>
The most important piece is the AddDir tag which tells the generator script where it finds the actual shader source files. More then one AddDir can be added if the shader sources are spread over different directories.
Generator scripts must also include a dirty-check and only actually overwrite the target C++ files when the source files (in this case: the XML file and all shader sources) are newer then the target sources to prevent unneeded compilation of dependent files.
Shader File Parsing
Shader files will be processed by a simple line-parser:
- comments and white-space will be removed
- find and process ‘@’ and ‘$’ keywords
- gather GLSL code lines and keep track of their source file and line numbers (this is important for mapping error messages back later)
A very minimal shader file looks like this:
@vs MyVertexShader
@uniform mat4 mvp ModelViewProj
@in vec4 position
@in vec2 texcoord0
@out vec2 uv
void main() {
$position = mvp * position;
uv = texcoord0;
@end
@fs MyFragmentShader
@uniform sampler2D tex Texture
@in vec2 uv
void main() {
$color = $texture2D(tex, uv);
}
@end
@bundle Main
@program MyVertexShader MyFragmentShader
@end
This defines one vertex shader (between the @vs and @end tags) and a matching fragment shader (between @fs and @end). The vertex shader defines a 4x4 matrix uniform with the GLSL variable name mvp and the ‘bind name’ ModelViewProj, and it expects position and texture coordinates from the vertex. The vertex shader transforms the vertex-position into the special variable $position and forwards the texture coordinate to the fragment shader.
The fragment shader defines a texture sampler uniform with the GLSL variable name tex and the bind name Texture. It takes the texture coordinates emitted by the vertex shader, samples the texture and writes the color into the special variable $color.
Finally a shader @bundle with the name ‘Main’ is defined, and one shader program created from the previously defined vertex- and fragment-shader is attached to the bundle. A shader bundle is an Oryol-specific concept and is simply a collection of one or more shader programs that are related to each other.
Another useful tag which isn’t used in this simple example are the @block and @use tag. A @block encapsulates a piece of code which can then later be included with a @use tag in other blocks or vertex-/fragment-shaders. This is basically the missing #include mechanism for GLSL files.
Here’s some @block sample code, first a Util block is defined with general utility functions, then a block VSLighting which would contain lighting functions for vertex shaders, and FSLighting with lighting functions for fragment shaders. Both VSLighting and FSLighting want to use functions from the Util block (via @use Util). Finally the vertex- and fragment-shaders would contain a @use VSLighting and @use FSLighting (not shown). The shader code generator would then resolve all block dependencies and include the required the code blocks in the generated shader source in the right order:
@block Util
// general utility functions
vec4 bla() {
vec4 result;
...
return result;
}
@end
@block VSLighting
// lighting functions for the vertex shader
@use Util
vec4 vsBlub() {
return bla();
}
@end
@block FSLighting
// lighting functions for the fragment shader
@use Util
vec4 fsBlub() {
return bla();
}
@end
GLSL Code Generation and Validation
From the ‘tagged shader source’, the shader generator script will create actual vertex- and fragment-shader code for different GLSL versions and feed it to the reference compiler for validation.
For instance, the above simple vertex/fragment-shader source would produce the following GLSL 1.00 source code (for OpenGLES2 and WebGL):
uniform mat4 mvp;
attribute vec4 position;
attribute vec2 texcoord0;
varying vec2 uv;
void main() {
gl_Position = mvp * position;
uv = texcoord0;
}
The output for a more modern GLSL version would look slightly different:
#version 150
uniform mat4 mvp;
in vec4 position;
in vec2 texcoord0;
out vec2 uv;
void main() {
gl_Position = mvp * position;
uv = texcoord0;
}
The GLSL reference compiler is called once per GLSL version and vertex-/fragment-shader and the resulting output is captured into a string variable. The python code to start an exe and capture its output looks like this:
child = subprocess.Popen([exePath, glslPath], stdout=subprocess.PIPE)
out = ''
while True :
out += child.stdout.read()
if child.poll() != None :
break
return out
The output will then be parsed for error messages and error line numbers. Since these line-numbers are pointing into the generated source code they are not useful themselves but must be mapped back to the original source-file-path and line-numbers. This is why the line-parser had to store this information with each extracted source code line.
The mapped source-file-path, line-number and error message must then be formatted into the gcc/clang- or VStudio-error-message format, and if an error occurs, the python script will terminate with an error code so that the build is stopped:
if platform.system() == 'Windows' :
print '{}({}): error: {}'.format(FilePath, LineNumber + 1, msg)
else :
print '{}:{}: error: {}\n'.format(FilePath, LineNumber + 1, msg)
if terminate:
sys.exit(10)
This formatting works for Xcode and VisualStudio. The error is displayed by the IDE and can be double-clicked to position the text cursor over the right source code location. It doesn’t work in Qt Creator yet unfortunately, and I haven’t tested Eclipse yet.
Another thing to keep in mind is that build jobs can run in parallel. At first I was writing the intermediate GLSL files for the reference compiler into files with simple filenames (like ‘vs.vert’ and ‘fs.frag’). This didn’t cause any problems when doing trivial tests, but once I had converted all Oryol samples to use the shader generator I was sometimes getting weird errors from the reference compiler which didn’t make any sense at first.
The problem was that build jobs were running at the same time and overwrote each others intermediate files. The solution was to use randomized filenames which cannot collide. As always, python has a module just for this case called ‘tempfiles’:
# this writes to a new temp vertex shader file
f = tempfile.NamedTemporaryFile(suffix='.vert', delete=False)
writeFile(f, lines)
f.close()
# call the validator
...
# delete the temp file when done
os.unlink(f.name)
The C++ Side
Last but not least a quick look at the generated C++ source code. The C++ header defines a namespace with the name of the shader-library, and one class per shader-bundle. The very simple vertex/fragment-shader sample from above would generate a header like this:
#pragma once
/* #version:1#
machine generated, do not edit!
*/
#include "Render/Setup/ProgramBundleSetup.h"
namespace Oryol {
namespace Shaders {
class Main {
public:
static const int32 ModelViewProj = 0;
static const int32 Texture = 1;
static Render::ProgramBundleSetup CreateSetup();
};
}
}
Note the ModelViewProj and Texture constant definitions. These are used to set the uniform values in the C++ render loop.
How this code is actually used for rendering is a topic of its own. For now let me just point to the Oryol sample source code:
https://github.com/floooh/oryol/tree/master/code/Samples/Render
What’s next
The existing shader tags are already quite useful but only the beginning. The real problem I want to solve is to manage slightly differing variations of the same shader. For instance there might exist a specific high-level material, which must be applied to static and skinned geometry (2 variations), can cast shadows (4 variations: static shadow caster, skinned shadow caster), should be available in a forward-renderer and deferred-renderer (== many more slightly different shader variations). Sometimes an ueber-shader approach is better, and sometimes actually separate shaders for each variation are better.
The guts of those material shaders are always built from the same small code fragments, just arranged and combined differently.
Hopefully a couple of new ‘@’ and ‘$’ tags will be enough, but how this will look like in detail I don’t know yet. One inspiration are web-template engines which build web pages from a set of templates and rules. Another inspiration are the existing connect-the-dots shader editors (even though I want to keep the focus on ‘shaders-as-source-code’, not ‘shader-as-data’, but some limited runtime-code-generation would still make sense).
And of course the right middle-ground between ‘modern GLSL’ and ‘legacy GLSL’ must be found. Unfortunately OpenGL ES2 / WebGL1.0 will have to be the foundation for quite some time.
And that’s all for today :)
Written with StackEdit.