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:
- Prepare a texture with our text written on. ← We'll take a little look into this.
- Prepare vertex and fragment shader. ← And into this one too.
- Prepare vertex buffer object (VBO). Since graphics programming is all about triangles (made of vertices, wow), thus the name.
- Create vertex array object (VAO), that tells the current shader program how to interpret data in the VBO.
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:
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