Part 4: Shader Animations
In the previous sections of these animation guides we've looked at hand coding animations, abstracting over them with signals, and composing complicated presentations using timelines.
In this section, in pursuit of performance, we going to come full circle back to hand coded animations, but this time, in the form of shaders.
This part of our tour isn't going to cover everything there is to know about shaders. Shaders in Indigo are typically written using our shader library, Ultraviolet, which has it's own documentation site, complete with many Indigo based examples.
As always the complete source code for this demo is available from the link on this page, if you want to see how the whole thing is put together. Therefore in this discussion, we'll be focusing on the reasons for using shader based animations, and the nuts and bolts of how one works.
The need for speed
If timeline animations are designed for friendly usability at the expense of performance overhead, shaders are the opposite. Super efficient, massively parallel, and really powerful; Shaders are a low level way to describe the graphics you want to draw into a region of the screen.
Whenever you draw literally anything with Indigo - whether you know it or not - a shader is doing the work. So if you need complex graphics, animations, and performance: Shaders might be the answer.
It must also be said that while they can be complicated and contain a fair bit of maths, shader programming is very good fun once you get into it!
Shaders, hand coding, and signals
Shaders are written in procedural code. Scala if you're using Ultraviolet, otherwise you'll be using a language called GLSL.
Despite that, philosophically, programming a shader is more like coding with signals than it is the hand coded solution we saw in part 1.
A fragment shader is not unlike a pure function that runs for every pixel of an image, and decides what color it is. It has to be pure, since it will be run in parallel, and like a pure function (or indeed a signal), it operates on some initial parameters (such as the running time) to generate its output, but can otherwise be considered stateless.*
(* You can add your own input data to a shader using UBOs, and those will typically be based on game model state. However, shaders in Indigo do not have a way to generate, store, look up, or share their own state.)
Example Links
Animating with shaders
In this example we're going to write a shader for a little bouncing yellow box, the kind that could be used to show the currently highlighted grid square in a grid based rpg or strategy game.
Let's go through the code:
As usual, animation is about the running time of the game, and in our case we need to speed time up in order to get the bounce speed we're after.
val time: Float = env.TIME * 4.0f
Next we're going to set up some constants / values / parameters. Yes, in this example these are hardcoded values. On the other hand, they're also relative values (shaders operate in 0.0 to 1.0 coordinate space), and if we wanted to we could pass them in using a UBO, but that isn't needed for this example.
val halfStrokeWidth: Float = 0.02f
val halfGlowWidth: Float = 0.07f
val minDistance: Float = 0.3f
val distMultiplier: Float = 0.05f
In order to describe the box shape, we're going to use something called an SDF (Signed Distance Function) that will tell us whether this pixel (fragment) is outside the box (> 0), on the edge of the box (~= 0), or inside the box (< 0).
We're using one of Ultraviolets built in helper functions to do that, and it requires two parameters:
- The position of this pixel (i.e. the UV coordinate), re-centered around the origin (0,0).
- The 'halfsize' of the box (can be a rectangle or a square).
What's important about the halfsize here is that this is the thing we're animating! We're using a sin wave, using the current time as the argument, pushing out the value by a minimum distance. This produces an SDF distance value for a box that is bouncing / changing size.
The argument to sin
should be an angle, but we're cheating a bit and using the
running time in seconds, which more or less does what we want.
val halfsize = vec2(minDistance + abs(sin(time) * distMultiplier))
val sdf = sdBox(env.UV - 0.5f, halfsize)
The SDF value is a smooth gradiant, so to make it a hard 'frame' (like a picture frame), we need to do two things to it:
- We need to make it 'annular', so that the values inside and outside the shader are positive, and it ends up looking like a top down view of a square crater.
- We need to use a
step
function to swap out the gradient for a hard edge.
val frame = 1.0f - step(0.0f, abs(-sdf) - halfStrokeWidth)
Time for some color! Colors in this example are represented as vec3
s, where (x, y, z)
are equivalent to (red, green, blue).
The main color (col
) is yellow, i.e. full red, full green, no blue.
Note that we're calculating the colors separately from the alphas, this is important.
val col = vec3(1.0, 1.0, 0.0f)
We know that the color is going to be yellow, but what we need to do now is work out the alpha of all that yellow. Obviouly, the alpha will be 0.0 outside the frame, and 1.0 inside the middle of the frame, but we also want a little glow effect, which is really just a gradiant ramp in the alpha channel.
The glow amount (alpha of the glow) is roughly calculated by looking at the current SDF value, and saying that if the value is between an upper and lower bound, than 'smoothstep' that value.
Smoothstepping is a process of interpolating between the upper and lower bound values, to produce a value between 0.0 and 1.0. For example, if the lower bound was 10, and the upper bound was 20, and the value was 15, we'd get a result of 0.5. It isn't quite as simple as that that because of the 'smooth' part. The value produced isn't a linear interpolation, its eased in and out based on an S curve.
The final alpha is the glow amount, knocked back by 50%, combined with the frame value which you may recall was either 0.0 or 1.0.
This can mean that by our process, a pixel could have an alpha value > 1.0, but since that's unrepresentable, it doesn't matter for our purposes and for all intents and purposes, the value will be clamped to a 0.0 to 1.0 range.
val glowAmount = smoothstep(0.95f, 1.05f, 1.0f - (abs(sdf) - halfGlowWidth))
val alpha = (glowAmount * 0.5f) + frame
Finally we're going to return the pixel colour, a vec4
, i.e. (red, green, blue,
alpha).
The thing to note here is that its been constructed with the vec3 colour value and the
alpha, i.e. vec4(vec3(r, g, b), a)
but curiously, the colour has been multiplied by
the alpha.
This is not an error! It's to do with something called pre-multiplied alpha, and is essential for the colours to come out looking right. (See the Ultraviolet docs for more details.)
vec4(col * alpha, alpha)