Shaders
EditShaders are small GPU programs that can be used to customize rendering or perform arbitrary computation. This guide explains how shaders work in LÖVR.
Contents
GLSL
Shaders are written in GLSL. GLSL is a lower-level language than Lua, so shaders are a bit harder to learn at first. Explaining GLSL is outside the scope of these docs, but some good ways to learn it are:
- The OpenGL wiki is a good reference.
- Copying random shaders from the internet and changing them until they do interesting things is another recommended approach.
LÖVR uses GLSL version 4.60.
Basics
There are 2 types of shaders, given by ShaderType:
graphicsshaders are used for rendering. They compute vertex positions and pixel colors.computeshaders run outside of the normal rendering flow. They can do arbitrary computation and write toBufferandTextureobjects. Think of it like calling a function on the GPU.
Shaders have one or more "stages", which are basically functions, given by ShaderStage:
graphicsshaders have 2 stages:- The
vertexstage computes vertex positions. It loads vertex data from meshes and applies transformations to get the final "on-screen" position for triangles. - The
fragmentstage computes pixel colors. It uses material data to compute lighting and other effects, returning the final color of the pixel.
- The
computeshaders just have a singlecomputestage.
Each Pass has an active shader it uses to process draws. Pass:setShader changes the active
shader. The active shader will affect all draws until the shader is changed again. When the active
shader is nil, LÖVR will use a built-in shader based on the type of draw (unlit for meshes,
font for text, etc.).
The set of shaders built in to LÖVR are given by DefaultShader.
Writing Shaders
LÖVR uses the lovrmain function as the GLSL entrypoint. For vertex stages, lovrmain should
return the final transformed vertex position. Here's the default vertex shader:
vec4 lovrmain() {
return Projection * View * Transform * VertexPosition;
}
It can also be written using the DefaultPosition shorthand:
vec4 lovrmain() {
return DefaultPosition;
}
The vertex position is multiplied by several matrices to get it into "normalized device coordinates", which is the coordinate space the GPU uses internally to process vertices.
For fragment shaders, lovrmain should return the final color of the pixel. Here's the default
fragment shader:
vec4 lovrmain() {
return Color * getPixel(ColorTexture, UV);
}
It can also be written using the DefaultColor shorthand:
vec4 lovrmain() {
return DefaultColor;
}
The default pixel color is calculated by multiplying the Color from the vertex stage (which
includes the vertex color, material color, and pass color) with a pixel sampled from the color
texture.
Compute shaders implement the void lovrmain() function, and don't return anything.
Shaders are created with lovr.graphics.newShader, with the code for each stage:
shader = lovr.graphics.newShader([[
vec4 lovrmain() {
return DefaultPosition;
}
]], [[
vec4 lovrmain() {
return DefaultColor;
}
]])
The code can also be loaded from a filename or a Blob. Also, a DefaultShader can be used for
the code of one or both of the stages:
shader = lovr.graphics.newShader('vertex.glsl', 'unlit')
Finally, for advanced use, lovr.graphics.newShader takes a raw option that will use raw GLSL
code without any LÖVR helpers:
shader = lovr.graphics.newShader([[
#version 460
void main() {
//
}
]], { raw = true })
Shader Builtins
The following built-in variables and macros are available in vertex and fragment shaders:
| Name | Type | Notes |
PI |
float | |
TAU |
float | (PI * 2) |
PI_2 |
float | (PI / 2) |
Resolution |
vec2 | The size of the render pass texture, in pixels. |
Time |
float | Current time in seconds (lovr.headset.getTime). |
CameraPositionWorld |
vec3 | The position of the current view (Pass:setViewPose). |
Sampler |
sampler | The default sampler (Pass:setSampler). |
The following built-in variables are available only in vertex shaders:
| Name | Type | Notes |
VertexPosition |
vec4 | The local position of the current vertex. |
VertexNormal |
vec3 | The normal vector of the current vertex. |
VertexUV |
vec2 | The texture coordinate of the current vertex. |
VertexUV2 |
vec2 | The second texture coordinate of the current vertex. |
VertexColor |
vec4 | The color of the current vertex. |
VertexTangent |
vec4 | The tangent vector of the current vertex (w is sign of tangent basis). |
Projection |
mat4 | The projection matrix of the current view (Pass:setProjection). |
View |
mat4 | The view matrix of the current view (Pass:setViewPose). |
ViewProjection |
mat4 | The projection matrix multiplied with the view matrix. |
InverseProjection |
mat4 | The inverse of the projection matrix. |
Transform |
mat4 | The model matrix (includes transform stack and draw transform). |
NormalMatrix |
mat3 | Transforms normal vectors from local space to world space. |
ClipFromLocal |
mat4 | Transforms from local space to clip space (Projection * View * Transform). |
ClipFromWorld |
mat4 | Transforms from world space to clip space (Projection * View). |
ClipFromView |
mat4 | Transforms from view space to clip space (Projection). |
ViewFromLocal |
mat4 | Transforms from local space to view space (View * Transform). |
ViewFromWorld |
mat4 | Transforms from world space to view space (View). |
ViewFromClip |
mat4 | Transforms from clip space to view space (InverseProjection). |
WorldFromLocal |
mat4 | Transforms from local space to world space (Transform). |
WorldFromView |
mat4 | Transforms from view space to world space (inverse(View)). |
WorldFromClip |
mat4 | Transforms from clip space to world space (inverse(ViewProjection)). |
PassColor |
vec4 | The color set with Pass:setColor. |
The following built-in variables and macros are available only in fragment shaders:
| Name | Type | Notes |
PositionWorld |
vec3 | The position of the pixel, in world space. |
Normal |
vec3 | The normal vector of the pixel, in world space. |
Color |
vec4 | The vertex, material, and pass colors multiplied together. |
UV |
vec2 | The texture coordinate of the current pixel. |
UV2 |
vec2 | The second texture coordinate of the current pixel. |
Tangent |
vec4 | The tangent vector of the current pixel, in world space. |
TangentMatrix |
mat3 | The tangent matrix, used for normal mapping. |
The properties of the active material, set using Pass:setMaterial, can be accessed in vertex and
fragment shaders. Textures can be sampled using the getPixel helper function. The Material and
lovr.graphics.newMaterial pages have more info on these properties.
| Name | Type | Notes |
Material.color |
vec4 | The material color. |
Material.glow |
vec4 | The material glow color (alpha is glow strength). |
Material.uvShift |
vec2 | The material UV shift. |
Material.uvScale |
vec2 | The material UV scale. |
Material.metalness |
float | The material metalness. |
Material.roughness |
float | The material roughness. |
Material.clearcoat |
float | The material clearcoat factor. |
Material.clearcoatRoughness |
float | The roughness of the clearcoat layer. |
Material.occlusionStrength |
float | The strength of the occlusion texture. |
Material.normalScale |
float | The strength of the normal map texture. |
Material.alphaCutoff |
float | The alpha cutoff. |
ColorTexture |
texture2D | The color texture. |
GlowTexture |
texture2D | The glow texture. |
OcclusionTexture |
texture2D | The ambient occlusion texture. |
MetalnessTexture |
texture2D | The metalness texture. |
RoughnessTexture |
texture2D | The roughness texture. |
ClearcoatTexture |
texture2D | The clearcoat texture. |
NormalTexture |
texture2D | The normal map. |
The following built-in variables are definitions for special GLSL built-in variables.
| Name | Type | Notes | Stage |
BaseInstance |
int | gl_BaseInstance |
Vertex |
BaseVertex |
int | gl_BaseVertex |
Vertex |
DrawIndex |
int | gl_DrawID |
Vertex |
InstanceIndex |
int | gl_InstanceIndex |
Vertex |
PointSize |
float | gl_PointSize |
Vertex |
Position |
vec4 | gl_Position |
Vertex |
VertexIndex |
int | gl_VertexIndex |
Vertex |
FragCoord |
vec4 | gl_FragCoord |
Fragment |
FragDepth |
float | gl_FragDepth |
Fragment |
FrontFacing |
bool | gl_FrontFacing |
Fragment |
PointCoord |
vec2 | gl_PointCoord |
Fragment |
SampleID |
int | gl_SampleID |
Fragment |
SampleMaskIn |
int[ ] | gl_SampleMaskIn |
Fragment |
SampleMask |
int[ ] | gl_SampleMask |
Fragment |
SamplePosition |
vec2 | gl_SamplePosition |
Fragment |
Shader Inputs
It's also possible to send values or objects from Lua to a Shader. There are a few different ways to do this, each with their own tradeoffs (speed, size, ease of use, etc.).
Uniforms
Shaders can declare uniforms, which can be booleans, numbers, vectors, or matrices. These have a constant or "uniform" value for all vertices/pixels that are drawn. They are easy to use and inexpensive to update, but they must be resent every frame and whenever the shader changes.
Uniforms are declared in shader code with the uniform keyword, and can be set with Pass:send:
function lovr.load()
shader = lovr.graphics.newShader('unlit', [[
uniform vec4 color1;
uniform vec4 color2;
vec4 lovrmain() {
// Apply a vertical gradient using the 2 colors from the uniforms:
return mix(color1, color2, dot(Normal, vec3(0, 1, 0)) * .5 + .5);
}
]])
end
function lovr.draw(pass)
pass:setShader(shader)
pass:send('color1', { 1, 0, 1, 1 })
pass:send('color2', { 0, 1, 1, 1 })
pass:sphere(0, 1.7, -2)
end
When the active shader is changed, uniforms with the same name and type will be preserved.
Vertex Attributes
Vertex attributes are the data for each vertex of a mesh. They should be used for data that varies on a per-vertex basis. Attributes have a name, a type, and a location (an integer ID). LÖVR uses the following default vertex attributes for shapes and meshes:
| Name | Type | Location |
| VertexPosition | vec4 | 10 |
| VertexNormal | vec3 | 11 |
| VertexUV | vec2 | 12 |
| VertexColor | vec4 | 13 |
| VertexTangent | vec4 | 14 |
Custom vertex attributes can be declared like this:
in vec3 customAttribute;
The data in a buffer can then be associated with the attribute, by name:
vertices = lovr.graphics.newBuffer(vertexCount, {
{ type = 'vec3', name = 'customAttribute' }
})
Buffers
Shaders can access data in Buffer objects. Buffers can store large arrays of data from Lua tables
or Blobs. The GPU can also write to buffers using compute shaders.
Data in buffers can be accessed in 2 ways:
- Uniform buffers have a smaller size limit, may be faster, and are read-only in shaders.
- Storage buffers have a large size limit, may be slower, and compute shaders can write to them.
First, the buffer should be declared in the shader. Here's an example declaring a uniform buffer:
uniform Colors {
vec4 colors[100];
};
And an example storage buffer:
buffer Colors {
vec4 colors[100];
};
First the uniform or buffer keyword is used to declare which type of buffer it is, followed by
the name of the variable. Finally, there is a block declaring the format of the data in the buffer,
which should match the format used to create the Buffer in Lua (structs can be used if the buffer
has multiple fields per element).
A Buffer can be sent to one of the above variables like this:
-- palette is a table with 100 colors in it
buffer = lovr.graphics.newBuffer('vec4', palette)
-- bind it to the shader later
pass:send('Colors', buffer)
The shader can then use the colors array to access the data from the palette table.
There is a very handy Shader:getBufferFormat method that will return a Buffer format from a
variable in a shader, so you don't have to duplicate it in the Lua code.
It's possible to bind a subset of a buffer to the shader by passing the range as extra arguments to
Pass:send.
Textures
Shaders can also access data from Texture objects. Similar to buffers, textures can be accessed
in 2 ways:
- Sampled textures are read-only, and can use
Samplerobjects. - Storage textures can be written to using compute shaders.
Sampled textures are declared like this:
uniform texture2D myTexture;
The texture type can be texture2D, textureCube, texture2DArray, or texture3D (see
TextureType).
Storage textures are declared like this:
uniform image2D myImage;
A texture can be sent to the shader variable using Pass:send.
The getPixel helper function can be used to sample from a texture:
getPixel(myTexture, UV)
This will sample from the texture using the UV coordinates and the default sampler set using
Pass:setSampler. It's the same as writing this for 2D textures:
texture(sampler2D(myTexture, Sampler), UV)
It's also possible to declare a custom sampler variable and use it to sample textures:
uniform sampler mySampler;
// texture(sampler2D(myTexture, mySampler), UV)
A Sampler object can be sent to the shader using Pass:send, similar to buffers and textures.
Flags
Shaders can declare "flags" (also called specialization constants), which are values that are constant in the shader, but can be overridden when creating the Shader object in Lua.
Shaders can be "cloned" using Shader:clone, which creates a copy of the shader with the option of
specifying different values for its flags.
There are 2 advantages to using shader flags, compared to using string manipulation to replace variables:
- The shader code for a clone does not need to be recompiled. This makes it much faster to create lots of different shaders with slightly different constants or behavior.
- The shader code can be precompiled ahead of time using
lovr.graphics.compileShaderand packaged with a game. Flags can then be used at runtime to specialize shaders based on information that can only be known at runtime, like something specific about the current GPU. This also reduces load times further, because GLSL does not need to be compiled at all.
Flags are declared using the constant_id qualifier, and can be overridden in
lovr.graphics.newShader and Shader:clone:
shader = lovr.graphics.newShader('unlit', [[
layout(constant_id = 0) const bool flag_forceColor = false;
layout(constant_id = 1) const float flag_r = 1;
layout(constant_id = 2) const float flag_g = 1;
layout(constant_id = 3) const float flag_b = 1;
layout(constant_id = 4) const float flag_a = 1;
vec4 lovrmain() {
if (flag_forceColor) {
return vec4(flag_r, flag_g, flag_b, flag_a);
} else {
return Color;
}
}
]], {
flags = {
forceColor = true,
g = 0,
b = 0.5
}
})
clone = shader:clone({
forceColor = true,
r = 1.0,
g = 0.0,
b = 0.8
})
LÖVR reserves constant_id values of 1000 and above. Flag names may be prefixed with flag_ to
separate them from other GLSL variables. The flag_ prefix will be stripped when matching against
flag table keys in lovr.graphics.newShader.
See the ShaderFlag page for a list of builtin flags, which can be used to control the behavior of
LÖVR's shader helpers and default shaders.
Built-in shader functions
Shaders can make use of the following built-in helper functions:
Texture Sampling
The getPixel function samples pixels from textures.
vec4 getPixel(texture2D t, vec2 uv)
vec4 getPixel(texture3D t, vec3 uvw)
vec4 getPixel(textureCube t, vec3 dir)
vec4 getPixel(texture2DArray t, vec2 uv, float layer)
vec4 getPixel(textureCubeArray t, vec4 coord)
These use the default sampler set with Pass:setSampler. However, getPixel can also take a
sampler variable instead of a texture variable, allowing a different sampler to be used instead:
vec4 color = getPixel(sampler2D(mytexture, mysampler), UV);
Lighting
LÖVR has some helpers that implement PBR shading.
Surface
Many of the lighting helpers take a Surface struct, which holds several light-independent data
needed for shading. The Surface can be created once for a pixel and reused to compute shading for
multiple lights.
// Note: positions and directions are in world space
struct Surface {
vec3 position; // Position of fragment
vec3 normal; // Includes normal mapping
vec3 geometricNormal; // Raw normal from vertex shader
vec3 view; // The direction from the fragment to the camera
vec3 reflection; // The view vector reflected about the normal
vec3 f0;
vec3 diffuse;
vec3 emissive;
vec4 baseColor;
float metalness;
float roughness;
float roughness2;
float occlusion;
float clearcoat;
float clearcoatRoughness;
};
Surfaces can be created with getDefaultSurface, which creates a surface using LÖVR's builtin
vertex shader inputs and parameters from the active Material. However, it is also possible to
create a Surface manually using newSurface, applyMaterial, and finalizeSurface.
Surface surface = getDefaultSurface();
// or, split into 3 steps, allowing each to be customized.
// newSurface creates a blank surface, filling in the pixel
// position and normal vector and leaving the material properties
// set to default values.
Surface surface = newSurface();
// applyMaterial fills in properties using the Material: base color,
// metalness, roughness, occlusion, emissive, clearcoat, and normal map.
applyMaterial(surface);
// finalizeSurface fills in some derived surface properties: f0,
// reflection vector, roughness2, etc. It also clamps and flips other
// properties if needed.
finalizeSurface(surface);
High Level
Once a Surface exists, it can be used with lighting helpers. The getLighting helper takes a
Surface, and information about a light, and returns the color of that pixel. It can be called for
multiple lights, with the contribution of each light adding to result in the final color of the
pixel:
vec3 getLighting(const Surface surface, vec3 direction, vec4 color, float visibility);
The getIndirectLighting helper returns indirect lighting coming from the environment (sky). It
takes an environment cubemap and a set of spherical harmonics coefficients:
vec3 getIndirectLighting(const Surface surface, textureCube environmentMap, vec3 sphericalHarmonics[9]);
Both the environment map and the spherical harmonics can be created from a skybox using the cmgen
tool from Filament.
$ ./bin/cmgen --type=cubemap --format=png -x out env.hdr
Low Level
The high level lighting helpers use several low-level helpers.
Direct lighting:
float D_GGX(const Surface surface, float NoH); // Specular
float G_SmithGGXCorrelated(const Surface surface, float NoV, float NoL); // Diffuse
vec3 F_Schlick(const Surface surface, float VoH); // Fresnel
Indirect lighting:
vec2 prefilteredBRDF(float NoV, float roughness);
vec3 evaluateSphericalHarmonics(vec3 sh[9], vec3 n);
Color Conversion
The following helper functions implement tone mapping and color space conversion.
ACES tonemapping is provided by the tonemap function. It squishes floating point light
intensities down into the 0-1 range, to avoid clipping.
vec3 tonemap(vec3 x);
To convert between linear and sRGB encoded colors, use gammaToLinear and linearToGamma, which
are similar to lovr.math.linearToGamma and lovr.math.gammaToLinear.
vec3 gammaToLinear(vec3 color);
vec3 linearToGamma(vec3 color);
For HDR10, pqToLinear and linearToPQ can convert between linear colors and PQ-encoded colors.
vec3 pqToLinear(vec3 color);
vec3 linearToPQ(vec3 color);
Finally, there are helpers for converting between the sRGB (BT.709) and Rec2020 (BT.2020) color spaces.
vec3 sRGBToRec2020(vec3 color);
vec3 rec2020ToSRGB(vec3 color);
Miscellaneous
These two functions are used for packing and unpacking data stored using the sn10x3 DataType:
uint packSnorm10x3(vec4 v);
vec4 unpackSnorm10x3(uint n);