0x4A41

Post

Wavy Text in WebGL2

I wanted to replicate the wavy text effect that you can see on old demoscene demos. So I did. Ah, I also wanted to do it using WebGL2.

I already had experience with OpenGL, due to the work on my thesis and other projects a couple of years ago (time flies), so it was quite straightforward.

First Step: Initializing WebGL

When working with OpenGL, we need to get its context. That usually means using a library like SDL or GLFW, unless you want to write specific OS code. For WebGL, having a <canvas> is enough.

<canvas width="512" height="256"></canvas>

And on the JavaScript side you simply ask the canvas to give you a WebGL2 context:

const canvas = document.querySelector("canvas");
const gl     = canvas.getContext("webgl2");

if (!gl) {
    // webgl2 isn't available, handle error, early exit, whatever
}

Second Step: Setting up WebGL

We have our context, now we can interact with WebGL as we would with OpenGL. The steps to follow, in no particular order, are:

Second Step: Part Two: On textures and text

In OpenGL, to get some text it's common to use FreeType. Which it's an amazing library, but its usage involves a lot of math, and that means a lot of code. For WebGL, while we could use a third-party library, there's actually an easier way of doing things.

What we do is, we simply draw our text in another <canvas>. And the <canvas>es are already available to be used as textures in WebGL.

const textCanvas = document.createElement("canvas"); {
    textCanvas.width = canvas.width;
    textCanvas.height = canvas.height;

    const ctx = textCanvas.getContext("2d");

    // Clear the background
    ctx.fillStyle = "black";
    ctx.fillRect(0, 0, canvas.width, canvas.height);

    // Draw our text
    ctx.fillStyle = "white";
    ctx.font = "48px sans-serif";
    ctx.textAlign = "center";
    ctx.textBaseline = "middle";
    ctx.fillText("hello, world", canvas.width/2, canvas.height/2);
}

That's all we need. Then, to create our WebGL texture, we simply pass the <canvas> element to the gl.texImage2D function as if it were raw pixels.

const textCanvas = document.createElement("canvas");

// ...

// Texture initialization
const textTexture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, textTexture);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);

// Load canvas into the texture
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, textCanvas);

Setting gl.UNPACK_FLIP_Y_WEBGL to true is required, as the canvas' coordinates are upside down relative to what WebGL does. There are different ways to solve this issue, but this one was the easier option.

Second Step: Part Three: On shaders

One could come up with a million ways of getting this effect, but this is the first one that came to my mind. It isn't perfect, as it doesn't exactly replicate what I'm looking for, but it's good and simple enough.

To give some context, if you've never worked with shaders before and are reading this, a shader is part of the programmable rendering stage. And in modern graphics programming they are also required. Or at least two of them: vertex and fragment shaders. There's also geometry, tesellation, compute, but we won't use them.

Vertex shaders work on, you guessed it, vertices. And in this case, we'll simply use a pass-through shader, to pass our vertices and texture coordinates to the fragment shader stage:

#version 300 es

in vec2 a_vertex;
in vec2 a_uv;

out vec2 v_uv;

void main() {
    v_uv = a_uv;
    gl_Position = vec4(a_vertex, 0.0, 1.0);
}

The fragment shader, or the shader that "makes pixels into pixels", is where we have some fun. We simply y-offset our UV coordinates so we can get a slanted effect (aka. the first step on having our wavy text).

#version 300 es
precision highp float;

uniform sampler2D u_texture;

in vec2 v_uv;

void main() {
    vec2 uv = v_uv;    // let's make a copy of our UV coordinates

    uv.y += uv.x - 0.5;

    outColor = texture(u_texture, uv);
}

Check out the demo below, uncheck the "enable sin()" option to see how it looks.

Anyways. Great. Now, we need to make it wave. If you loved trigonometry, or you've worked with the likes of sin, cos, tan you can see where we're going. By passing the time parameter to our shader we can make our uv.y fluctuate, up and down. So, the next step for our UV manipulation looks like this:

uv.y += sin(uv.x + u_time);

Now, depending on the speed the time is being sampled, it could be obscenely fast or obscenely slow. Hopefully, it works great. In practice, we add some more parameters to manipulate the behaviour of our sin():

uv.y += sin(uv.x * u_freq + u_time * u_speed) * u_amplitude;

In the end, our final fragment shader looks like this:

#version 300 es
precision highp float;

uniform sampler2D u_texture;
uniform float     u_time;
uniform float     u_freq;
uniform float     u_speed;
uniform float     u_amplitude;

in vec2 v_uv;

out vec4 outColor;

void main() {
    vec2 uv = v_uv;

    uv.y += sin(uv.x * u_freq + u_time * u_speed) * u_amplitude;

    outColor = texture(u_texture, uv);
}

Conclusion

Here's the final result:


freq
speed
amplitude
time
text

I invite you to check the source code of this page to see how the whole thing is actually tied together. There's a lot of boilerplate code when it comes to graphics programming, and I tried to keep it to a minimum in this post.

Wishing you well,
0x4a41