Что такое shader compilation process
Шейдеры. Что и как
Расскажу, как в общем случае они работают, что умеют и как их используют
Сразу оговорюсь, что материал рассчитан на тех, кто никогда не работал с шейдерами или вообще не знаком с разработкой игр, то есть это в некотором смысле научпоп.
Слово «шейдер» в контексте разработки игр очень популярно, слышать его могли и те, кто игры не делает. Само слово изначально появилось от англ. shading (затенение) — первые шейдеры использовались, чтобы передавать глубину с помощью работы со светом, блеском, тенями и прочим. Со временем шейдеры стали использоваться для совершенно разного вида постобработки и вообще отрисовки примерно всего.
Говоря общими словами, шейдер — это просто программа для графической карты. То есть то, что пишется школьниками на паскале (хипстерами на пайтоне) — это программы для вашего центрального процессора (CPU), а шейдеры — для графического (GPU). Особенность же этих программ выходит из особенностей GPU — они работают параллельно на сотнях маленьких ядех вместо нескольких больших, преимущественно осуществляя математические операции.
Теперь разберемся, как это все работает.
В общем случае цель шейдера — отрисовать некоторый объект. Поэтому возьмем куб, распишем процесс его отрисовки и посмотрим, где используются шейдеры и зачем. Сначала опишем сам куб. Для графической карты это 8 точек, между некоторыми из которых есть плоскость. Каждая из точек описывается тремя числами (правильно сказать, что это вершины). Помимо этого у кубика есть цвет и положение внутри мира.
Процесс отрисовки, если его достаточно упростить (что я и сделаю в рамках этой статьи), можно поделить на несколько шагов:
1. Получение входных данных из памяти.
2. Выполнение шейдера вершин.
3. Растеризация.
4. Выполнение шейдера пикселей (фрагментов).
5. Проведение тестов «глубины».
6. Отрисовка на текстуру для экрана.
В первом шаге видеокарта каким-то образом получает данные (вершины, плоскости, текстуры) в свою видеопамять, для нас это сейчас не так важно. Далее происходит конвертация координат относительно объекта в координаты на экране относительно камеры. После происходит растеризация — высчитывается, в каких пикселях уже на экране находится объект. Такие пиксели называют фрагментами. Отличие от пикселей заключается в том, что фрагмент помимо информации о пикселе, содержит еще и некоторую побочную информацию, полученную после растеризации. Для упрощения будем считать, что это все просто пиксели на экране. Далее для каждого пикселя выполняется шейдер фрагмента. А затем проверяется, что расстояние от камеры до фрагмента соответствует высчитанному заранее в нужном направлении в буфере глубины. Проще говоря, проверяется, нет ли перед объектом чего-либо еще, и нужно ли его отрисовывать на итоговое изображение.
Как видите, в процессе отрисовки можно заметить два вида шейдера. На самом деле, сейчас есть чуть больше видов, но они не столь важны для разбора, так как имеют более специфичный характер использования, а мы рассказываем на пальцах. Но вот те два, что нас волнуют:
1. Шейдер вершин.
2. Шейдер фрагментов.
Как сказано было ранее, этот шейдер (или группа шейдеров по очереди) занимается переводом координат относительно объекта, в координаты на текстуре.
На картинке начало координат немного не соответствует реальным, что все так же не влияет на понимание процесса 🙂
Пройдемся по состояниям. В первом у нас, очевидно, входные координаты без излишков. На втором они были перенесены в координаты относительно начала «мира». Потом они переносятся в координаты относительно точки смотрящего (видно на второй картинке), но заметно, что картинка плоская. Их проекция происходит далее и мы получаем наши итоговые координаты. Все эти операции производятся шейдером. Помимо прочего, он позволяет не только отобразить реальные координаты, но и модифицировать их так, чтобы исказить объект для эффекта. Например, я недавно писал шейдер, который переворачивал спрайт, чтобы отрисовать его тень:
После преобразований вершин и растеризации нужно высчитать цвет каждого фрагмента (помним, что для упрощения это пиксели). Для примера возьмём наш куб: мы помним, что он залит одним цветом. Просто сделаем так, чтобы цвет каждого фрагмента стал цвета куба и все:
Выглядит немного странно, да? Проблема в том, что мы не видим ни теней, ни текстур. Будь на кубе какая-либо текстура, мы бы заметили переход между гранями. Вот возьмем текстуру:
Теперь достаточно в каждом пикселе просто брать цвет из текстуры. Но чтобы это сделать, нужно добавить для каждой точки куба еще информацию: UV канал. Это координат вида (u, v). Отсюда и название, так как x и y были заняты. Она присваивается вершине объекта и обозначает точку на текстуре, которая ей соответствует. Чтобы было понятнее, если мы хотим на каждую грань куба нарисовать знакомое нам лицо, то UV координаты для каждой грани будут выглядеть предельно просто:
Модифицировать их никак не надо. Более того, считать координаты для каждой точки — тоже не нужно. Этим занимается GPU, самостоятельно интерполируя точки из вершин. Тогда достаточно просто сказать для каждой точки что-то вроде
Это очень условный пример, но примерно так в простейшем случае оно и работает:
Помимо натягивания текстур в пиксельном шейдере можно, например, получить информацию об освещенности и добавить к цвету черного пропорционально затемнению в этой точке, тогда объект будет менее плоским. Это, конечно, если просчет освещенности где-то написан, потому что сама по себе видеокарта о понятиях освещения и теней мало чего знает. Вообще делать с цветами можно что угодно, например подменять их в зависимости от игрока:
Помимо UV канала в шейдер в зависимости от его вида приходят разные данные. Они зависят от игрового движка и графической библиотеки. Обычно туда входят данные о местоположении точки, нормаль (вектор исходящий от поверхности в точке), UV канал. Но также шейдер вершин может передавать данные в пиксельный шейдер. Если это координата, то она будет проинтеполирована на основе положения фрагмента относительно вершин, между которыми он находится, как, например, UV данные.
Shader Compilation
Shader Compilation is the term used to describe the process by which OpenGL Shading Language scripts are loaded into OpenGL to be used as shaders. OpenGL has three ways to compile shader text into usable OpenGL objects. All of these forms of compilation produce a Program Object.
Contents
Shader and program objects
A Program Object can contain the executable code for all of the Shader stages, such that all that is needed to render is to bind one program object. Building programs that contain multiple shader stages requires a two-stage compilation process.
This two-stage compilation process mirrors the standard compile/link setup for C and C++ source code. C/C++ text is first fed through a compiler, thus producing an object file. To get the executable code, one or more object files must be linked together.
With this method of program creation, shader text is first fed through a compiler, thus producing a shader object. To get the executable program object, one or more shader objects must be linked together.
Shader object compilation
The first step is to create shader objects for each shader that you intend to use and compile them. To create a shader object, you call this function:
Once you have a shader object, you will need to give it the actual text string representing the GLSL source code. That is done via this function:
When the shader is compiled, it will be compiled as if all of the given strings were concatenated end-to-end. This makes it easy for the user to load most of a shader from a file, but to have a standardized preamble that is prepended to some group of shaders.
The length can be either NULL or an array of count integers. These are the lengths of the corresponding strings in the string array. This allows you to use non-NULL-terminated strings. If you pass NULL, then OpenGL will assume all of the strings are NULL-terminated and will therefore compute the length in the usual way.
Once shader strings have been set into a shader object, it can be compiled with this function:
It compiles the given shader.
Shader error handling
Compilation may or may not succeed. Shader compilation failure is not an OpenGL Error; you need to check for it specifically. This is done with a particular call to glGetShaderiv :
Shader compilation is pass/fail, but it is often useful to know why. This, like in most languages, is provided as text messages. OpenGL allows you to query a log containing this information. First, you must use glGetShaderiv to query the log’s length:
This tells you how many bytes to allocate; the length includes the NULL terminator. Once you have the length and have allocated sufficient memory, you can use this function to get the log:
Shader compilation error checking.
Program setup
Once you have successfully compiled the shader objects of interest, you can link them into a program. This begins by creating a program object via this command:
The function takes no parameters.
After creating a program, the shader objects you wish to link to it must be attached to the program. This is done via this function:
This can be called multiple times with different shader objects.
Before linking
A number of parameters can be set up that will affect the linking process. This generally involves interfaces with the program. These include:
You cannot change these values after linking; if you don’t set them before linking, you can’t set them at all.
Program linking
Linking can fail for many reasons, including but not limited to:
Program link failure can be detected and responded to, in a similar way to shader compilation failure.
Once the program has been successfully linked, it can be used.
Linking and variables
Normally, shader objects for different shader stages don’t interact. Each shader stage’s code is separate from others. They have their own global variables, their own functions, etc.
This is not the case entirely. Certain definitions are considered shared between shader stages. Specifically, these include uniforms, buffer variables, and buffer-backed interface blocks.
If one of these is defined in one stage, another stage can define the same object with the same name and the exact same definition. If this happens, then there will only be one uniform/buffer variable/interface block visible from the introspection API. So shader stages in the same program can share uniform variables, allowing the same value to be set into both stages with one glUniform call.
For this to work however, the definitions must be exactly the same. This includes the order of the members, any user-defined data structures they use, array counts, everything. If two definitions in different stages have the same name, but different definitions, then there will be a linker error.
Cleanup
After linking (whether successfully or not), it is a good idea to detach all shader objects from the program. This is done via this function:
Example
Full compile/link of a Vertex and Fragment Shader.
Separate programs
Core in version | 4.6 | |
---|---|---|
Core since version | 4.1 | |
Core ARB extension | ARB_separate_shader_objects |
A program object can contain the code for multiple shader stages. The glUseProgram function only takes a single program, so you can only use a single program at a time for rendering. Therefore, you cannot mix-and-match code for different shader stages dynamically post-linking. Shader objects are not programs; they only hold compiled fragments of code, not fully useful programs.
There is a way to do this. This involves two alterations to the model presented above. The first is how the program is created; the second is in how it gets used.
To allow the use of multiple programs, where each program only provides some of the shader stage code, we must first create our programs specially. To signal that a program object is intended to be used with this separate program model, we must set a parameter on the program before linking. This is done as follows:
There is an alternative method for creating separable programs. This represents a common use case: creating a program from a single set of shader source which provides the code for a single shader stage. The function to do this is:
This works exactly as if you took count and strings strings, created a shader object from them of the type shader type, and then linked that shader object into a program with the GL_PROGRAM_SEPARABLE parameter. And then detaching and deleting the shader object.
This process can fail, just as compilation or linking can fail. The program infolog can thus contain compile errors as well as linking errors.
Separable programs are allowed to have shaders from more than one stage linked into them. While it is best to only use shaders from one stage (since the main point of using separable programs is the ability to mix-and-match freely), you do not have to. However, if two stages are linked together in the same program, you will be unable to insert another program between those two stages, due to pipeline validation rules.
Program pipelines
Creating a separable program is just the first step. The other thing you must do is change how the program is used.
To use multiple separable programs, they must first be assembled into an OpenGL Object type called a program pipeline. Unlike program or shader objects, these follow the standard OpenGL Object mode. Therefore, there is a glGenProgramPipelines function to create new pipeline names, a glDeleteProgramPipelines to delete them, and a glBindProgramPipeline to bind it to the context. Program pipeline objects do not have targets, so the last function only takes the pipeline to be bound.
Similar to Sampler Objects, program pipeline objects should only be bound when you intend to render with them (or set uniforms through them, as described below). The only state in program pipeline objects are the list of programs that contain the code for the various shader stages. This state is set by this function:
program must either be 0 or a separable program.
Program pipeline objects are container objects. As such, they cannot be shared across multiple OpenGL contexts.
Rendering
After binding a pipeline, you can then render with those stages as normal, or dispatch compute work. Program pipelines can also be validated.
Uniforms and pipelines
glUniform changes uniform state on the currently used program. However, with separate programs and program pipelines, the definition of «currently used program» is much more complicated. It works as follows.
OpenGL first checks the program currently set by glUseProgram (you can bind separable programs with this function). If a program is bound through this function, then that is the currently used program. If no program is set by this function (ie: if you execute glUseProgram(0) ), then the next step occurs.
The currently bound program pipeline is checked. Program pipelines have the concept of an active program. The active program of a program pipeline object is the «currently used program» (again, only if a program isn’t in use by glUseProgram ).
The active program for a pipeline is set by this function:
program must be a valid, successfully linked program object. It may not be zero; you cannot unset an active program once you set it onto the pipeline object.
Examples of separate programs
Two separate programs for vertex and fragment shading
Creating two separable programs, one with a vertex shader and one with a fragment shader.
Mixing a single- and a multi-stage program
Creates a separate program, where some of the stages are directly linked together.
Binary upload
Core in version | 4.6 | |
---|---|---|
Core since version | 4.1 | |
Core ARB extension | ARB_get_program_binary |
Compiling and linking shaders, regardless of which method you use, can take a long time. The more shaders you have, the longer this process takes. It is often useful to be able to cache the result of program linking, so that this cached program can be reloaded much faster.
This is done via a set of calls. Given a successfully linked program, the user can fetch a block of binary data, in a certain format, that represents this program. The first step of this process is to get the length of this data by calling glGetProgram with GL_PROGRAM_BINARY_LENGTH on the program. Armed with this length, the actual binary can be obtained with this function:
Given the format and the binary data, a new program object can be created with this binary data. This is done via this function:
State of the program
Program objects contain certain state. The program binary only encapsulates the state of the program at the moment linking was successful. This means that all uniforms are reset to their default values (either specified in-shader or 0). Vertex attributes and fragment shader outputs will have the values assigned, as well as transform feedback data, interface block bindings, and so forth.
If glProgramBinary is successful, it should result in a program object that is identical to the original program object as it was immediately after linking.
If the original program was separable, then the program built from the binary will also be separable. And vice-versa.
Binary limitations
Program binary formats are not intended to be transmitted. It is not reasonable to expect different hardware vendors to accept the same binary formats. It is not reasonable to expect different hardware from the same vendor to accept the same binary formats.
Indeed, you cannot expect the cached version to work even on the same machine. Driver updates between when the data was cached and when it is reloaded can change the acceptable binary formats. Therefore, glProgramBinary can fail frequently. If you use this functionality, you must have a fallback for creating your shaders if the binary is rejected.
SPIR-V compilation
Core in version | 4.6 | |
---|---|---|
Core since version | 4.6 | |
Core ARB extension | ARB_spirv_extensions | |
ARB extension | ARB_gl_spirv |
SPIR-V’s compilation model looks similar to that of GLSL, but it does have some unique characteristics.
As with GLSL, SPIR-V makes use of shader and program objects. Because SPIR-V is a binary format, SPIR-V shaders are loaded into shader objects via the use of the shader binary API:
shaders is an array of count length of previously created shader objects that the SPIR-V data will be loaded into. So this function can load the same SPIR-V source code into multiple shader objects.
Entry points and specialization
SPIR-V is similar to GLSL, but it has some differences. Two differences are particularly relevant.
Before a SPIR-V shader object can be used, you must specify which entry-point to use and provide values for any specialization constants used by that entry-point. This is done through a single function:
pEntryPoint is the string name of the entry-point that this SPIR-V shader object will use. pConstantIndex and pConstantValue are arrays containing the index of each specialization constant and the corresponding values that will be used. These arrays are numSpecializationConstants in length. Specialization constants not referenced by pConstantIndex use the default values specified in the SPIR-V shader.
pEntryPoint must name a valid entry point. Also, the entry point’s «execution model» (SPIR-V speak for «Shader Stage») must match the stage the shader object was created with. Specialization can also fail if pConstantIndex references a specialization constant index that the SPIR-V binary does not use. If specialization fails, the shader’s info log is updated appropriately.
Once specialized, SPIR-V shaders cannot be re-specialized. However, you can reload the SPIR-V binary data into them, which will allow them to be specialized again.
Linking SPIR-V
SPIR-V shader objects that have been specialized can be used to link programs (separable or otherwise). If you link multiple shader objects in the same program, then either all of them must be SPIR-V shaders or none of them may be SPIR-V shaders. You cannot link SPIR-V to non-SPIR-V shaders in a program.
Also, note that SPIR-V shaders must have an entry-point. So SPIR-V modules for the same stage cannot be linked together. Each SPIR-V shader object must provide all of the code for its module.
You can use separable programs built from SPIR-V shaders in the same pipeline object as non-SPIR-V shaders.
Error handling
Program linking can fail for various reasons. Linking happens when creating a separate program as well as when loading a program binary into a program object. All of these processes can fail.
To determine if linking has failed, call glGetProgramiv :
If isLinked is GL_FALSE (aka: zero), then the most recent linking operation on that program failed (or the program has never been linked). Otherwise it succeeded.
Program linking is pass/fail, but it is often useful to know why it failed. Like in most languages, linker errors are provided as text messages. OpenGL allows you to query a log containing these errors. To do so, you must first query the log’s length using glGetProgramiv again:
This tells you how many bytes to allocate; the length includes the NULL terminator. Once you have the length and have allocated sufficient memory, you can use this function to get the log:
Program Linking error checking.
Interface matching
Shaders in stages have inputs and outputs. Most values output by shaders directly feed subsequent shader stage input variables. There are rules for how these must match.
When linking multiple shader stages together, these rules are checked at program linking time. Therefore, mismatching interfaces between stages are linker errors. However, the interface between separable programs in a pipeline can only be checked at runtime, when the pipeline is used.
Directly linking multiple shader stages together requires that all outputs from one stage are consumed by inputs from the next active stage and vice versa. Failure to do this results in a linker error. However, separable programs do not have exactly match between the separate programs. The results of an inexact match are described below.
Outputs and inputs can either be in an Interface Block or as loose output/input variables. The rules for matching differ between them.
An output interface block matches an input interface block if:
For loose variables, an output matches with an input if:
Qualifier matching
Type qualifiers between output variables in one stage and input variables with the same name in the consuming stage must match. These matches are required to be exact, except for these cases:
All other qualifiers must match for variables with the same names.
Array interfaces and shader stages
Certain shader stages automatically aggregate their inputs or outputs into arrays. For example, Geometry Shaders take an array of elements, where each array index represents a vertex. Tessellation Control Shaders take an array of inputs and writes to arrays of outputs, with each index representing a vertex.
For the purpose of determining an interface match, such aggregate inputs or outputs are considered not to be arrays (or if the interface variable is an array that itself contains arrays, the first array dimention is ignored). patch qualified outputs/inputs for Tessellation Control/Evaluation Shaders are not aggregated in arrays, so they don’t count here. If you make a patch variable that is an array, the array index must match between the control and evaluation shaders.
Separate program matching
Separate programs are allowed to have matches that are not exact (ie: where every output is not consumed by every input).
However, a mismatch can only lead to defined behavior if loose variables are used and those variables use layout(location) qualifiers. All other input variables will have undefined values on an non-exact match. This includes input variables that match under the regular rules that don’t use the layout(location) qualifier. If the match is not exact, then only those matching using layout(location) qualifiers will work.
Validation
A program object, or program pipeline object, must be valid to be used in rendering operations. As much of this validity is checked at link-time as possible; however, some of it references the current OpenGL state. Therefore, some of it must be tested at runtime. For program pipelines, some validity that would have been checked at link-time for non-separable programs (such as interface matching) must be checked at runtime.
The validity of a program or pipeline object can be checked at any time using these functions:
Here are the rules of program object validation:
Pipeline validation
Pipeline object validation must also check the following: