Notes / Shaders / Gradient

The Warhammer 40k Adeptus Mechanicus symbol

Table of contents

    Gradient shader

    Planted: 

    Status: seed

    Intended Audience: Creative coders and front-end developers with a basic understanding of WebGL shaders.

    How to create an organic gradient animation using a WebGL shader. If you're new to shaders, check out this note. What we'll make:

    Render shader

    First we render a simple shader — a flat green surface.

    This requires:

    • a canvas element in the DOM
    • vertex and fragment shader files
    • a program to compile these files
    • a render loop

    /index.html

    <canvas id="canvas"></canvas>

    /index.js

    import vertSrc from "./shader.vert?raw";
    import fragSrc from "./shader.frag?raw";
    const canvas = document.querySelector("#canvas");
    const gl = canvas.getContext("webgl2");
    function compileShader(type, src) {
    const shader = gl.createShader(type);
    gl.shaderSource(shader, src);
    gl.compileShader(shader);
    if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
    throw new Error(gl.getShaderInfoLog(shader));
    }
    return shader;
    }
    // A program links a vertex shader and fragment shader into one GPU pipeline.
    const program = gl.createProgram();
    gl.attachShader(program, compileShader(gl.VERTEX_SHADER, vertSrc));
    gl.attachShader(program, compileShader(gl.FRAGMENT_SHADER, fragSrc));
    gl.linkProgram(program);
    // WebGL2 requires a bound VAO to draw.
    gl.bindVertexArray(gl.createVertexArray());
    function render() {
    gl.clearColor(0, 0, 0, 1);
    gl.clear(gl.COLOR_BUFFER_BIT);
    gl.useProgram(program);
    // Process 3 vertices (positions hardcoded in vertex shader)
    gl.drawArrays(gl.TRIANGLES, 0, 3);
    requestAnimationFrame(render);
    }
    requestAnimationFrame(render);

    Normally, you'd pass a geometry — like a flat plane — from JavaScript to the vertex shader, then manipulate its vertex positions to create a desired shape. However, we just need a flat surface. So, we use a shortcut. Instead of passing in a geometry, I define three points inside the vertex shader. They create a triangle big enough to cover the viewport. The GPU clips the triangle, leaving a viewport sized rectangle.

    /shader.vert

    #version 300 es
    void main() {
    // The vertex shader requires coordinates to be in clip space for its output variable `gl_Position`.
    // Clip space: [-1, -1] (bottom-left) -> [1, 1] (top-right)
    vec2 pos[3] = vec2[](
    vec2(-1.0, -1.0), // bottom-left
    vec2( 3.0, -1.0), // bottom-right
    vec2(-1.0, 3.0) // top
    );
    // gl_VertexID is 0, 1, or 2 — coming from gl.drawArrays(TRIANGLES, 0, 3)
    gl_Position = vec4(pos[gl_VertexID], 0.0, 1.0);
    }

    /shader.frag

    #version 300 es
    precision highp float;
    out vec4 fragColor;
    void main() {
    // green
    fragColor = vec4(0.0, 1.0, 0.0, 1.0);
    }

    smoothstep() + mix()

    Two built-in functions we'll use are smoothstep and mix. They allow us to create a linear gradient.

    I wired some variables from the JS file to a GUI, then passed them into the fragment shader (see sending data for more details). In the shader, I first get the UV — the normalized coordinates of the fragment.

    The shader provides us with gl_FragCoord, which contains the fragment's coords relative to screen resolution (window-space position). In this coord system, the bottom-left is [0,0] and the top-right might be [1920,1080] (depending on screen size). We normalise these to make things easier to work with. I'm normalizing to [-1, -1] -> [1, 1] because having [0, 0] at the center is ideal for our requirements.

    Next, I use smoothstep. It takes three numbers — a start, an end and a value in between. It returns a fraction of where that value lies within start to end. For example, it returns:

    • 0 if the value is at the start
    • 1 if at the end
    • and 0.5 if in the middle

    I set the return value as t then pass it to mix with two colors. If:

    • t == 0, it returns the first color.
    • t == 1, it returns the second color.
    • anything in between will be a mix of the two.

    /shader.frag

    #version 300 es
    precision highp float;
    uniform vec2 u_resolution;
    uniform float u_edge0;
    uniform float u_edge1;
    uniform vec3 u_colorA;
    uniform vec3 u_colorB;
    out vec4 fragColor;
    vec2 getUV(vec4 fragCoord, vec2 resolution) {
    vec2 uv = (fragCoord.xy / resolution) * 2.0 - 1.0;
    // prevent warping because viewport isn't square
    uv.x *= resolution.x / resolution.y;
    return uv;
    }
    void main() {
    vec2 uv = getUV(gl_FragCoord, u_resolution);
    float t = smoothstep(u_edge0, u_edge1, uv.y);
    vec3 color = mix(u_colorA, u_colorB, t);
    fragColor = vec4(color, 1.0);
    }

    Sine wave

    To create an organic animation, we base the gradient on a sine wave instead of a straight line.

    To render the wave:

    • get the UV
    • pass the fragment's x-coord to a sine wave function to get the wave's y-coord
    • call renderWaveLine — a function that uses smoothstep and mix to return:
      • black if this fragment is far from the line,
      • a tiny black to white gradient if close or
      • white if on the line

    /shader.frag

    ...
    out vec4 fragColor;
    vec2 getUV(vec4 fragCoord, vec2 resolution) {
    vec2 uv = (fragCoord.xy / resolution) * 2.0 - 1.0;
    uv.x *= resolution.x / resolution.y;
    return uv;
    }
    float sinWave(float freq, float amp, float phase, float x) {
    return amp * sin(freq * x + phase);
    }
    vec3 renderWaveLine(float resolutionY, vec3 backgroundColor, vec3 lineColor, float uvY, float waveY) {
    // antialiasing: make the line thicker than 1 pixel and fade it out with smoothstep
    float aa = 2.0 / resolutionY;
    float distanceFromWave = abs(uvY - waveY);
    float waveLine = smoothstep(aa, 0.0, distanceFromWave);
    return mix(backgroundColor, lineColor, waveLine);
    }
    void main() {
    vec2 uv = getUV(gl_FragCoord, u_resolution);
    float waveY = sinWave(u_frequency, u_amplitude, u_phase, uv.x);
    vec3 color = renderWaveLine(u_resolution.y, u_backgroundColor, u_waveColor, uv.y, waveY);
    fragColor = vec4(color, 1.0);
    }

    Organic wave

    A sine wave is more organic than a straight line — but it's uniform — which isn't organic. We can fix this by combining sine waves with different properties:

    /shader.frag

    ...
    float wave =
    sinWave(u_frequency[0], u_amplitude[0], u_phase[0], uv.x) +
    sinWave(u_frequency[1], u_amplitude[1], u_phase[1], uv.x) +
    sinWave(u_frequency[2], u_amplitude[2], u_phase[2], uv.x);
    ...

    Organic gradient

    Now we create a wave-based gradient by rendering one colour on the wave and transitioning to another as we move away from it.

    This is done by:

    • calculating each fragment's distance to the wave
    • mapping that value to a number between 0 and 1 (to get the t value)
    • passing t to mix with two colors

    /shader.frag

    ...
    void main() {
    vec2 uv = getUV(gl_FragCoord, u_resolution);
    float wave = sinWave(u_frequency[0], u_amplitude[0], u_phase[0], uv.x)
    + sinWave(u_frequency[1], u_amplitude[1], u_phase[1], uv.x)
    + sinWave(u_frequency[2], u_amplitude[2], u_phase[2], uv.x);
    // signed distance from the wave: positive above, negative below
    float dist = uv.y - wave;
    // map dist from [-1,1] to [0,1]
    float t = clamp(dist * 0.5 + 0.5, 0.0, 1.0);
    vec3 color = mix(u_gradColor[0], u_gradColor[1], t);
    fragColor = vec4(color, 1.0);
    }

    Color banding

    The above approach results in color banding — visible lines where the color changes abruptly. This is most noticeable at gradient endpoints (when we render 100% of one color). We can fix this by applying an easing function to t before passing it to mix. I'm using cosine easing, which eases in and out at both ends of the gradient:

    /shader.frag

    ...
    t = 0.5 - 0.5 * cos(t * 3.14159265);
    vec3 color = mix(u_gradColor[0], u_gradColor[1], t);
    ...

    Animation

    The last step is to add animation.

    This requires adding time to each wave's phase value:

    /index.js

    ...
    const clock = new THREE.Clock();
    function render() {
    const elapsed = clock.getElapsedTime(); // seconds since start
    gl.uniform1f(u.time, elapsed);
    ...
    }
    ...

    /shader.frag

    ...
    float wave =
    sinWave(u_frequency[0], u_amplitude[0], u_wavePhase[0] + u_time * u_waveSpeed[0], uv.x) +
    sinWave(u_frequency[1], u_amplitude[1], u_wavePhase[1] + u_time * u_waveSpeed[1], uv.x) +
    sinWave(u_frequency[2], u_amplitude[2], u_wavePhase[2] + u_time * u_waveSpeed[2], uv.x);
    ...

    Feedback

    Have any feedback about this note or just want to comment on the state of the economy?

    Where to next?

    Shaders
    Arrow pointing downYOU ARE HERE
    A Johnny Cab pilot