5 - Shaping Functions

Chapter 5 - Shaping Functions

In chapter 5 of the Book of Shaders we are introduced to the essential kata of shaders, shaping functions. These are functions as in C-programming-language-like functions, but they are also functions as in mathematical pure functions. As pure functions, given the same input they always give the same output. (This includes shaping functions that include pseudo-random processes, if the RNG is always same-seeded.) We use these functions in our shaders to "shape" features like pixel colour across across the shader, or movement within the shader over time.

GLSL includes a number of built-in shaping functions. The built-in smoothstep is explored below. We can also build our own custom shaping functions through the compositino of built-in math functions like sin, abs, sign, clamp, min, max, mod, fract, ceil, floor, etc.

Like with the other chapters my shader explorations led to a number of "side quests". I proudly went down the rabbit holes of bit-mapped text output and 2D signed-distance functions. The end result was an animated gallery of shaping functions and these hypnotic waves:

Download 5.2-purple-waves.frag or interact with the ShaderToy port of this shader.

Plotting Smooth Lines

In chapter 4 we learn how to graphically plot 2D y = mx + b-like functions. The plotting is anti-aliased using a lovely shaping trick that uses two smoothstep calls to produce a soft line:

// Plot a line on Y using a value between 0.0-1.0
// st = normalized texture coordinates pct = % from 0.0 to 1.0
float plotIt(vec2 st, float pct){
const float halfSmoothWidth = 0.006;
return smoothstep(pct - halfSmoothWidth, pct, st.y)
- smoothstep(pct, pct + halfSmoothWidth, st.y);
}

This function took me a while to grok. More than a while. Thinking in pixels is tricky.

Sinusoidal Movement Over Time

The first shader I made using these technics was an animated sine wave. The background of the shader is a gradient produced by the sin function, with an x/y plot of the sin function overlaid.

The function was plotted using the smoothstep trick show above. To produce the gradient the x position of each pixel is used to produce a black-to-white colour intensity:

// Assume `x` contains the current pixel's x position
float y = sin(x * PI + u_time); // y is a sinusoid based on x position and time.
y = 0.5 * y + 0.5; // Scale and offset y to center plot.
// Which we could turn into a black to white grayscale colour like so:
float color = vec3(y, y, y);
// Or more consisely:
float color = vec3(y);

University is 20 years back for me, so I'd completely forgotten the subtleties of sin. Spent 20 minutes messing with this interactive sin plot to relearn some trig fundamentals.

All pixel positions used in the shader were normalized to a 0.0 to 1.0 range as a function of shader resolution:

vec2 st = gl_FragCoord.xy / u_resolution.xy;

Animated Sine Wave - Shader

Animated Sine Wave - GLSL Source

#ifdef GL_ES
precision mediump float;
#endif
// Handy to have this magic number around.
#define PI 3.14159265358979
// Request shader resolution and execution time as uniforms.
uniform vec2 u_resolution;
uniform float u_time;
// Plot a line on Y using a value between 0.0-1.0
// st = texture coordinates pct = % from 0.0 to 1.0
float plotIt(vec2 st, float pct){
const float halfSmoothWidth = 0.006;
return smoothstep(pct - halfSmoothWidth, pct, st.y)
- smoothstep(pct, pct + halfSmoothWidth, st.y);
}
// The Animated Sine Wave Shader
void main() {
const vec3 plotColour = vec3(.38, .31, .58); // Plot colour.
vec2 st = gl_FragCoord.xy / u_resolution.xy; // Normalize texture coords from 0.0 to 1.0.
float y = sin(st.x * PI + u_time); // Shape with sine of x position and time.
y = 0.5 * y + 0.5; // Scale and offset y to center plot.
float plot = plotIt(st, y); // Create a smooth plot of y.
float colour = y; // Black to white background gradient.
colour -= plot; // Remove gradient "below" where plot will be drawn.
colour = max(colour, 0.0); // Negative values become zero.
vec3 colours = vec3(colour) // Create the black to white gradient.
+ vec3(plot) * plotColour; // Overlay the plot.
gl_FragColor = vec4(colours, 1.0); // Set pixel colour.
}

Download 5.0-animated-sine-wave.frag or interact with the ShaderToy port of this shader.

Shaping Function Gallery

The next shader, I realize now, was quite ambitious. It's an animated gallery of shaping functions, where each function is use to generate:

  • A black to white background gradient based on the x position of each pixel.
  • A 2D x/y graph of the function plotted in purple. (Bottom left corner is x == 0 and y == 0.)
  • The easing movement of an orange circle from the bottom left corner to the top right one.

Along with this I wanted to:

  • Display the current shaping formula as text rendered to the shader.
  • Display a time-to-next-function progress bar along the bottom of the shader.

Shaping Gallery - Shader

This shader took me a few days to hack together, but boy did I learn a lot!

Shaping Gallery - Rabbit Holes

Before we get to the source code, here are some of the rabbit holes I followed:

  • Ultra Smooth Function Plotting - The smoothstep-based plotting worked well until I got to functions like tan that included step curves, at which point the plot line would go jagged or discontinuous. I eventually stumbled across and then use this distance-based anti-aliased technique. I fully admit that I don't actually understand how this code works, but the plot lines it produces are silky smooth!
  • Text Rendering - I wanted to be able to print out the current function being visulized as text, but GLSL doesn't have native text output. I played around with font textures but settled on a bitmapped-font technique. This could be super handy for debugging too!
  • Array Constructors: WebGL is built on the OpenGL 2.0 spec, this means Googling for code samples sometimes fails because OpenGL 3.x or 4.x features won't work. I wanted to store arrays of bitmapped characters to build the display strings but 2.0 doesn't allow for array literals or array constructors. Building the text string arrays was a chore. This is not an issue in ShaderToy, but I didn't bother refactoring with literals for the ShaderToy port.
  • 2D Signed Distance Circle Easing - While trying to figure out how the above mentioned ultra smooth plotting worked I stumbled across signed distance functions, which look to be a lovely way to represent and display 2D and 3D primitives. Inigo Quilez's articles on the subject seem like a treasure trove. The orange circle in the gallery shader was inspired by iq's 2D SDF functions page.
  • Normalizing Coordinates - Normalized texture coordinates in GLSL shaders are often labeled uv or st, with different coders normalizing the ranges in different ways. This blog post on GLSL Programming Tricks helped me figure out how to use two different coordinate normalizations in the shader (one for the text and one for everything else):
vec2 st = gl_FragCoord.xy / u_resolution.xy; // Normalize texture coords from 0.0 to 1.0.
vec2 tt = gl_FragCoord.xy / u_resolution.yy; // Normalize based only on y resolution for text.

From that post, here are a few different coordinate normalization tactics that allow you to change the shader's point of origin (x == 0 and y == 0):

vec2 R = u_resolution.xy,
vec2 st = fragCoord / R.y; // [0,1] vertically
st = ( 2.*fragCoord - R ) / R.y; // [-1,1] vertically
st = ( fragCoord - .5*R ) / R.y; // [-1/2,1/2] vertically
st = ( 2.*fragCoord - R ) / min(R.x,R.y); // [-1,1] along the shortest side

Notice that the pixel position fragCoord is normalized by the resolution to fall within a range of shader coordinates. Shader coders usually use the variable names uv or st for these texture coordinate vectors, but there appears to be a subtle difference between the two that I don't get yet. The variable names uv and st don't stand for anything. Each character in the name refers to one of the floats contained in the vec2. Instead of x/y coordinates we have u/v coordinates or s/t coordinates.

Shaping Gallery - GLSL Code

Here's the WebGL GLSL 2.x source for the above shaping gallery shader. I've tried my best to break things down into named and commented functions.

There's a lot of code here. Many functions. Skip to the bottom first and read through main() before exploring the rest.

#ifdef GL_ES
precision mediump float;
#endif
#define PI 3.1415926535897
#define DISPLAY_TIME_IN_SECONDS 6.
#define NUMBER_OF_FUNCTIONS 8
uniform vec2 u_resolution;
uniform float u_time;
// Bitmap text rendering from: https://www.shadertoy.com/view/4dtGD2
// Also see: https://www.shadertoy.com/view/4s3fzl
#define _f float
const lowp _f CH_A = _f(0x69f99), CH_B = _f(0x79797), CH_C = _f(0xe111e),
CH_D = _f(0x79997), CH_E = _f(0xf171f), CH_F = _f(0xf1711),
CH_G = _f(0xe1d96), CH_H = _f(0x99f99), CH_I = _f(0xf444f),
CH_J = _f(0x88996), CH_K = _f(0x95159), CH_L = _f(0x1111f),
CH_M = _f(0x9f999), CH_N = _f(0x9bd99), CH_O = _f(0x69996),
CH_P = _f(0x79971), CH_Q = _f(0x69b5a), CH_R = _f(0x79759),
CH_S = _f(0xe1687), CH_T = _f(0xf4444), CH_U = _f(0x99996),
CH_V = _f(0x999a4), CH_W = _f(0x999f9), CH_X = _f(0x99699),
CH_Y = _f(0x99e8e), CH_Z = _f(0xf843f), CH_0 = _f(0x6bd96),
CH_1 = _f(0x46444), CH_2 = _f(0x6942f), CH_3 = _f(0x69496),
CH_4 = _f(0x99f88), CH_5 = _f(0xf1687), CH_6 = _f(0x61796),
CH_7 = _f(0xf8421), CH_8 = _f(0x69696), CH_9 = _f(0x69e84),
CH_APST = _f(0x66400), CH_PI = _f(0x0faa9), CH_UNDS = _f(0x0000f),
CH_HYPH = _f(0x00600), CH_TILD = _f(0x0a500), CH_PLUS = _f(0x02720),
CH_EQUL = _f(0x0f0f0), CH_SLSH = _f(0x08421), CH_EXCL = _f(0x33303),
CH_QUES = _f(0x69404), CH_COMM = _f(0x00032), CH_FSTP = _f(0x00002),
CH_QUOT = _f(0x55000), CH_BLNK = _f(0x00000), CH_COLN = _f(0x00202),
CH_LPAR = _f(0x42224), CH_RPAR = _f(0x24442), CH_END = _f(0x99999);
// Returns the status of a bit in a bitmap. See above URL for more details.
float getBit( in float map, in float index )
{
return mod( floor( map*exp2(-index) ), 2.0 );
}
// Draws a character from it's bitmap. See above URL for more details.
float drawChar( in float char, in vec2 pos, in vec2 size, in vec2 uv )
{
uv = (uv - pos) / size;
float res = step(0.0,min(uv.x,uv.y)) - step(1.0,max(uv.x,uv.y));
uv *= vec2(4,6);
res *= getBit(char, 4.0*floor(uv.y) + floor(uv.x));
return clamp(res,0.,1.);
}
// Given an array of bitmap floats, draw out the characters.
float textArray(in vec2 uv, in float[20] string) {
const float spacing = 0.05;
const vec2 charSize = vec2(spacing * .8, spacing * .8);
vec2 charPos = vec2(0.04, 0.92);
float chr = 0.0;
for (int i = 0; i < 20; i++) {
chr += drawChar( string[i], charPos, charSize, uv);
charPos.x += spacing;
}
return chr;
}
// Which function are we currently displaying? Index from 0 to NUMBER_OF_FUNCTIONS-1
int functionIndexBasedOnTime() {
return int(mod(u_time / DISPLAY_TIME_IN_SECONDS, float(NUMBER_OF_FUNCTIONS)));
}
// Pick and apply the indexed function.
float pickFunction(float x) {
float y;
int index = functionIndexBasedOnTime();
if (index == 0) { // sin(pi/2 * x)
y = sin(.5 * PI * x);
} else if (index == 1) { // 1/2 + (sin(2 * pi * x) / 2)
y = sin(x * 2. * PI);
y = .5 * y + .5;
} else if (index == 2) { // abs(sin(2 * pi * x))
y = abs(sin(2. * PI * x));
} else if (index == 3) { // 1/2 + (tan((pi * x) - pi/2) / 10)
y = tan(x * PI - (.5 * PI));
y = .1 * y + .5;
// Don't antialias the infinity that lies just beyond 0 and 1.
if (x < 0.) { y = 0.0; }
if (x > 1.) { y = 100.; }
} else if (index == 4) { // x^5
y = pow(x, 5.);
} else if (index == 5) { // smoothstep(0.1, 0.9, x)
y = smoothstep(.1, .9, x);
} else if (index == 6) { // smoothstep(0.3, 0.5, x) - smoothstep(0.5, 0.7, x)
y = smoothstep(.3, .5, x) - smoothstep(.5, .7, x);
} else if (index == 7) { // Found: https://www.shadertoy.com/view/3sKSWc
float time = mod(u_time, 100.);
x *= 5.;
y = sin(x * sin(time * .2)) * sin(6. * x + cos(time * time * .001)) * .5 + .5;
}
return y;
}
// Pick and display the text rendering of the indexed function.
float pickText( in vec2 uv )
{
float s[20];
int index = functionIndexBasedOnTime();
// WebGL doesn't support an array literal constructor. So we have assign chars by index.
if (index == 0) { // sin(pi/2 * x)
s[0] = CH_S;
s[1] = CH_I;
s[2] = CH_N;
s[3] = CH_LPAR;
s[4] = CH_PI;
s[5] = CH_SLSH;
s[6] = CH_2;
s[7] = CH_X;
s[8] = CH_RPAR;
} if (index == 1) { // 1/2 + (sin(2 * pi * x) / 2)
s[0] = CH_1;
s[1] = CH_SLSH;
s[2] = CH_2;
s[3] = CH_PLUS;
s[4] = CH_S;
s[5] = CH_I;
s[6] = CH_N;
s[7] = CH_LPAR;
s[8] = CH_2;
s[9] = CH_PI;
s[10] = CH_X;
s[11] = CH_RPAR;
s[12] = CH_SLSH;
s[13] = CH_2;
} else if (index == 2) { // abs(sin(2 * pi * x))
s[0] = CH_A;
s[1] = CH_B;
s[2] = CH_S;
s[3] = CH_LPAR;
s[4] = CH_S;
s[5] = CH_I;
s[6] = CH_N;
s[7] = CH_LPAR;
s[8] = CH_2;
s[9] = CH_PI;
s[10] = CH_X;
s[11] = CH_RPAR;
s[12] = CH_RPAR;
} else if (index == 3) { // 1/2 + (tan((pi * x) - pi/2) / 10)
s[0] = CH_1;
s[1] = CH_SLSH;
s[2] = CH_2;
s[3] = CH_PLUS;
s[4] = CH_T;
s[5] = CH_A;
s[6] = CH_N;
s[7] = CH_LPAR;
s[8] = CH_PI;
s[9] = CH_X;
s[10] = CH_HYPH;
s[11] = CH_PI;
s[12] = CH_SLSH;
s[13] = CH_2;
s[14] = CH_RPAR;
s[15] = CH_SLSH;
s[16] = CH_1;
s[17] = CH_0;
} else if (index == 4) { // x^5
s[0] = CH_P;
s[1] = CH_O;
s[2] = CH_W;
s[3] = CH_LPAR;
s[4] = CH_X;
s[5] = CH_COMM;
s[6] = CH_5;
s[7] = CH_RPAR;
} else if (index == 5) { // smoothstep(0.1, 0.9, x)
s[0] = CH_S;
s[1] = CH_M;
s[2] = CH_O;
s[3] = CH_O;
s[4] = CH_T;
s[5] = CH_H;
s[6] = CH_S;
s[7] = CH_T;
s[8] = CH_E;
s[9] = CH_P;
} else if (index == 6) { // // smoothstep(0.3, 0.5, x) - smoothstep(0.5, 0.7, x)
s[0] = CH_QUOT;
s[1] = CH_S;
s[2] = CH_M;
s[3] = CH_O;
s[4] = CH_O;
s[5] = CH_T;
s[6] = CH_H;
s[7] = CH_L;
s[8] = CH_I;
s[9] = CH_N;
s[10] = CH_E;
s[11] = CH_QUOT;
} else if (index == 7) { // See pickFunction above. :)
s[0] = CH_S;
s[1] = CH_E;
s[2] = CH_E;
s[3] = CH_BLNK;
s[4] = CH_S;
s[5] = CH_O;
s[6] = CH_U;
s[7] = CH_R;
s[8] = CH_C;
s[9] = CH_E;
s[10] = CH_BLNK;
s[11] = CH_COLN;
s[12] = CH_RPAR;
}
return textArray(uv, s);
}
// Used for smooth function plotting see: https://www.shadertoy.com/view/3sKSWc
float distanceToLineSegment(vec2 p0, vec2 p1, vec2 p) {
float distanceP0 = length(p0 - p);
float distanceP1 = length(p1 - p);
float l2 =pow(length(p0 - p1), 2.);
float t = max(0., min(1., dot(p - p0, p1 - p0) / l2));
vec2 projection = p0 + t * (p1 - p0);
float distanceToProjection = length(projection - p);
return min(min(distanceP0, distanceP1), distanceToProjection);
}
// Used for smooth function plotting see: https://www.shadertoy.com/view/3sKSWc
float distanceToFunction(vec2 p, float xDelta) {
float result = 100.;
for (float i = -3.; i < 3.; i += 1.) {
vec2 q = p;
q.x += xDelta * i;
vec2 p0 = vec2(q.x, pickFunction(q.x));
vec2 p1 = vec2(q.x + xDelta, pickFunction(q.x + xDelta));
result = min(result, distanceToLineSegment(p0, p1, p));
}
return result;
}
// Used for smooth function plotting see: https://www.shadertoy.com/view/3sKSWc
float plotIt(vec2 st, float pct){ // (st = texture coordinates) (pct = % from 0.0 to 1.0)
float width = 4.;
float distanceToPlot = distanceToFunction(st, 1. / u_resolution.x);
return smoothstep(0., width, width - distanceToPlot * u_resolution.y);
}
// Draws a progress bar along the bottom of the texture.
float progressBar(vec2 st) {
float width = 0.01;
float seconds = mod(u_time, DISPLAY_TIME_IN_SECONDS) / DISPLAY_TIME_IN_SECONDS;
return float(st.y < width && st.x < seconds);
}
// Signed distance circle with an offset. See: https://www.shadertoy.com/view/3ltSW2
float sdCircle(vec2 p, float r, vec2 offset)
{
p.xy -= offset.xy; // Position the circle with an x/y offset.
p.y *= u_resolution.y / u_resolution.x; // Unskew circle based on aspect ratio.
return (1. - sign(length(p) - r)) / 2.; // Are we inside (1) or outside (0) the circle?
}
void main() {
// Colours for our various on-screen components.
const vec3 plotColour = vec3(0.46, 0.37, 0.74);
const vec3 textColour = vec3(.9, .15, .3);
const vec3 progressColour = vec3(0., .33, .58);
const vec3 circleColour = vec3(0.850,0.591,0.227);
vec2 st = gl_FragCoord.xy / u_resolution.xy; // Normalize texture coords from 0.0 to 1.0.
vec2 tt = gl_FragCoord.xy / u_resolution.yy; // Normalize based only on y resolution for text.
float y = pickFunction(st.x); // Pick and calculate a function.
float text = pickText(tt); // Pick and create the bitmapped text and offset shadow.
float textShadow = pickText(tt + vec2(-.003, .004));
float progress = progressBar(st); // Render the progress bar.
float plot = plotIt(st, y); // Smooth plot the function.
// Plot the circle easing it's position by the selected function across three second intervals.
float circle = sdCircle(st, 0.04, vec2(pickFunction(mod(u_time, 3.) / 3.)));
float gradient = min(y, 1.0); // Black to white background gradient.
gradient *= (1. - plot); // Remove gradient "below" where plot will be drawn.
gradient *= (1. - progress); // Remove gradient "below" where the progress bar will be drawn.
gradient *= (1. - circle); // Remove gradient "below" where the circle will be drawn.
gradient *= (1. - text); // Remove gradient "below" where text will be drawn.
gradient *= (1. - textShadow);// Remove gradient to leave a text shadow.
gradient = max(gradient, 0.0); // Negative values become zero.
// Putting everything together with colours.
vec3 colour = vec3(gradient)
+ vec3(plot) * plotColour
+ vec3(text) * textColour
+ vec3(progress) * progressColour
+ vec3(circle) * circleColour;
gl_FragColor = vec4(colour, 1.); // Set pixel colour.
}

Download 5.1-shaping-gallery.frag or interact with the ShaderToy port of this shader.

Up next we'll be exploring colours, gradients, and colour spaces.