Tutorial: Basic SPIR-V Shaders with Custom Material

This is a simple tutorial for anybody who wishes to take advantage of Defold 1.9.2’s SPIR-V shader update. The only prerequisite for this tutorial is that you’re familiar with writing basic GLSL shaders for Defold. I’ll be outlining the process for creating a simple color-fill effect, demonstrated in the following GIF.

The white rectangle (dubbed a “brick” in this tutorial) is a sprite with a custom material, vertex shader, and fragment shader that conform to SPIR-V . The red color (dubbed “damage” in this tutorial) is controlled by a custom vertex attribute.

Here’s the corresponding animation code.

-- URL of the brick sprite in the bootstrap collection.
local url = msg.url(nil, hash("/brick"), "sprite")

-- Defold allows us to animate not only properties set with `go.property()`, but also properties set in the material file.
-- In this case, we animate the custom `damage` property from 0.0 to 1.0 over two seconds.
go.animate(url, "damage", go.PLAYBACK_ONCE_FORWARD, 1.0, go.EASING_OUTBOUNCE, 2)

The custom material assigned to the sprite is shown below.

It’s mostly the same as the default sprite material, however it uses the new brick.vp and brick.fp shaders, and adds the custom damage vertex attribute, which is a float that should be assigned values between 0.0 and 1.0.

Let’s take a look at the vertex shader. I commented everything I thought was worth commenting. Please take some time to read and understand it. I made an attempt to use terminology and naming conventions consistent with Defold’s built-in shaders.

// SPIR-V requires a minimum GLSL version of 1.4,
// probably because GLSL 1.4 corresponds to OpenGL 3.1,
// which is when modern programmable pipeline features were introduced.
// https://en.wikipedia.org/wiki/OpenGL_Shading_Language#Versions
#version 140

////////////////////////////////////////////////////////////////////////////////
// Inputs
////////////////////////////////////////////////////////////////////////////////

// By default, Defold sprites receive `position` and `texcoord0` attributes.
// Notice that we use `in` instead of `attribute` when specifying attributes.
// I purposefully omit precision specifiers like `highp` for two reasons:
// 1. I trust the compiler to choose a reasonable precision.
// 2. I'm targeting PC, which uses OpenGL / Vulkan, not OpenGL ES.
//    If you're targeting embedded devices, it might be smart to add a precision.
in vec4 position;
in vec2 texcoord0;

// This is the custom `damage` attribute.
in float damage;

////////////////////////////////////////////////////////////////////////////////
// Uniforms
////////////////////////////////////////////////////////////////////////////////

// GLSL differentiates between "opaque" and "non-opaque" uniforms.
// Opaque in the dictionary means "non-transparent" or "you can't see through it".

// Opaque uniforms are handles or pointers to some memory location on the GPU.
// You can't "see through" these handles. You can't directly view or modify the data they point to.
// An example is `sampler2D`, which requires the use of a function like `texture()` to access the underlying data.

// Non-opaque uniforms however, you can "see through". You can directly access the data they reference.
// An example is `mat4`, which can be directly multiplied, assigned a value, etc.

// When writing SPIR-V GLSL, non-opaque uniforms must be wrapped in a "uniform block".
// A uniform block is like a `namespace` in C++, to some extent. It simply groups data together.

// Following these rules, I wrap the non-opaque `view_proj` matrix in a uniform block called `general_vp`, or "general vertex program".
// Why this name? Two reasons:
// 1. Its purpose is just to hold all non-opaque uniforms, regardless of context.
// 2. Uniform blocks cannot have the same name across the vertex and fragment shaders.
//    If  I wanted a similar block in the fragment shader, I'd differentiate it with a `_fp` suffix.
uniform general_vp
{
    mat4 view_proj;
};

////////////////////////////////////////////////////////////////////////////////
// Outputs
////////////////////////////////////////////////////////////////////////////////

// Outputs of a vertex shader are passed to the fragment shader.
// Notice that we use `out` instead of `varying` when specifying outputs.
out vec2 var_texcoord0;

// This is the custom `damage` attribute that I want to pass to the fragment shader to create that red color effect.
// Notice I use the `flat` keyword. This is called an "interpolation qualifier".
// As you know, outputs are interpolated between vertices, hence the old `varying` keyword and Defold's `var_` naming convention.
// I don't want my `damage` value to be interpolated.
// As the fragment shader moves rightward across the brick, `damage` should remain its correct value.
// After all, it wouldn't make sense for the damage of a character to change based on the position of its pixels. That would be really weird lol.
// I therefore replace Defold's `var_` prefix with my own `flat_` prefix.
flat out float flat_damage;

////////////////////////////////////////////////////////////////////////////////
// Functions
////////////////////////////////////////////////////////////////////////////////

void main()
{
    // When accessing `view_proj` from the uniform block, there is no need to specify the `general_vp` identifier.
    gl_Position = view_proj * vec4(position.xyz, 1.0);
    var_texcoord0 = texcoord0;
    flat_damage = damage;
}

The job of the vertex shader is no different than the default sprite vertex shader: set the vertex’s position, then pass color-related data to the fragment shader. The only thing I added was the declaration of the damage attribute, then passed it along.

If there are SPIR-V-related errors in your code, Defold will print a rather user-friendly string to the console describing the error. If your shaders are more complicated than the ones shown in this tutorial (they probably are), then you may run into other minor SPIR-V requirements that trigger compilation errors.

Next, take the time to read and understand the fragment shader. Most of it should make sense after reading through the vertex shader.

#version 140

////////////////////////////////////////////////////////////////////////////////
// Inputs
////////////////////////////////////////////////////////////////////////////////

// Inputs should of course match the vertex shader's outputs.
in vec2 var_texcoord0;
flat in float flat_damage;

////////////////////////////////////////////////////////////////////////////////
// Uniforms
////////////////////////////////////////////////////////////////////////////////

// As mentioned in the vertex shader, a `sampler2D` is an opaque uniform.
// Remember that opaque unforms do not require a uniform block.
uniform sampler2D texture_sampler;

////////////////////////////////////////////////////////////////////////////////
// Outputs
////////////////////////////////////////////////////////////////////////////////

// The final color of the fragment was set with `glFragColor`.
// Instead, our SPIR-V shaders should list a single output variable, which acts as the final color.
out vec4 color;

////////////////////////////////////////////////////////////////////////////////
// Functions
////////////////////////////////////////////////////////////////////////////////

void main()
{
    // As the character takes damage, the red color rises upward and fills the brick.
    // Ignoring atlas-related coordinate complications,
    // we simply need to set the fragment color to red if its y texture coordinate is less than `damage`.
    // For example, if `damage` is 0.5 (the character is 50% dead), then the red color will reach halfway up the brick.
    // We can employ this trick because texture coordinates are normalized from 0.0 to 1.0, as is `damage`.
    if (var_texcoord0.y < damage)
    {
        // Mix the original white color with a shade of red.
        // If `damage` is low, it will be a dark shade of red.
        // If `damage` is high, it will be a light shade of red.
        color = texture(texture_sampler, var_texcoord0) * vec4(flat_damage, 0, 0, 0);
    }
    else
    {
        // Simply use the original white color.
        color = texture(texture_sampler, var_texcoord0);
    }
}

That’s everything there is to know about writing a very basic SPIR-V shader pipeline. If you have questions, feel free to post them in this thread and I’ll try to answer them.

Thanks for reading, and happy Defolding.

17 Likes

I think it might be worth noting here that the texturecoords are referring to the entire texture (0.0 - 1.0). So comparing like tc.y < 0.5 won’t work if you have your image inside an atlas (it may be placed anywhere inside the texture).