Tytr

A code blog

👁‍🗨❤️👨🏻‍💻

Back to index

Lines!

Let's talk about drawing lines with shaders! I'm not going to spend any time talking about environment setup here. I'll assume that most folks have already chosen a language/environment/graphics API/etc. If you haven't and you want a recommendation, 100% just use WebGL and ThreeJS. ThreeJS is a beautiful library that let's you skip through all the bullshit and just start writing some shaders. Just start a project using one of the vite templates, add ThreeJS as a dependency, and get going! If you have no idea what shaders even are, please read my previous article where I briefly introduce them.

While I'm not going to dive super deep on javascript semantics and any of that, I will do some quick intro here just to introduce threejs and the geometry I'm setting up to be able to actually run the shader on an object. Here's a bunch of ThreeJS stuff to get our scene up and running.


import * as Three from "three";

const vshader = `
`;

const fshader = `
`;

function initScene() {
  const scene = new Three.Scene();
  const camera = new Three.OrthographicCamera( -1, 1, 1, -1, 0.1, 10 );

  const renderer = new Three.WebGLRenderer();
  renderer.setSize( window.innerWidth, window.innerHeight );
  document.body.appendChild( renderer.domElement );

  const geometry = new Three.PlaneGeometry( 2, 2 );
  const material = new Three.ShaderMaterial( {
    vertexShader: vshader,
    fragmentShader: fshader,
    transparent: true
  } );

  const plane = new Three.Mesh( geometry, material );
  scene.add( plane );

  camera.position.z = 1;

  onWindowResize();

  animate();

  function onWindowResize() {
    const aspectRatio = window.innerWidth/window.innerHeight;
    let width, height;
    if (aspectRatio>=1){
      width = 1;
      height = (window.innerHeight/window.innerWidth) * width;

    } else {
      width = aspectRatio;
      height = 1;
    }
    camera.left = -width;
    camera.right = width;
    camera.top = height;
    camera.bottom = -height;
    camera.updateProjectionMatrix();
    renderer.setSize( window.innerWidth, window.innerHeight );
  }

  function animate() {
    requestAnimationFrame( animate );
    renderer.render( scene, camera );
  }
}

Whew! There's a lot going on here. Let's try and chew through it a bit at a time. Here I'm creating a ThreeJS scene with an Orthographic camera. Then I'm attaching the renderer to the body of the page, and creating the geometry for our scene. Here I'm creating a 2x2 plane and attaching a material using some empty shader variables. You can look through the rest as your time/energy allows. Suffice it to say that this is basically the bare minumum we need to get our three js scene up and running.

Now, let's fill in the shaders with simplest possible code and see what happens.

    
 const initialVShader = `
    void main() {
        gl_Position = vec4(position, 1.0);
    }
 `;

 const initialFShader = `
    void main() {
        gl_FragColor = vec4(0.0, 0.5, 0.8, 1.0);
    }
 `;
    

There's a few quirks to cover here. The gl_Position value being set by the vertex shader is just mapping the vertex to the position passed in by threejs. Normally when using a low level graphics API we would actually have to write quite a bit of code to pass the vertex values to the GPU memeory. We would then have to set up an attribute to bring in the proper value. Here Threejs handles all of that for us behind the scenes, so we can just use the position variable like we did all of that work.

The fragment shader just sets every fragment to a nice blue color. You should mess with the values in the vec4 constructor and see how it changes the color! This vec4 is being interpreted as a color with rgba values. That's red, green, blue, and alpha, from 0.0 - 1.0. Alpha is just opacity, so 1.0 means fully opaque, or visible, and 0.0 means fully transparent, or invisible. vec4(1.0, 1.0, 1.0, 1.0) is white, and can also be expressed with the shorthand vec4(1.0), since all the values are the same. vec4(0.0, 0.0, 0.0, 1.0) is black. vec4(1.0, 0.0, 0.0, 1.0) would be red, and so on. Hopefully you have some intuition for how this color value is working now.

Let's try something fun now! Remember these shaders are attached to a plane that is essentially covering our entire "screen". It's made up of only 4 vertices. So let's change where those vertices are drawn! We can make our plane half the size by just halving the position! This will give you a feel for how simple it is to do vector math in GLSL! Change you vertex shader to the following:

    
const vshader = `
    void main() {
        gl_Position = vec4(position * 0.5, 1.0);
    }
`;
    

You can see that I've also changed the color of mine here, but that's not important. Alright, time to do the thing! Let's draw a line. For each fragment we will have access to where it is in the object. We can use that information to ask the following question: Does this fragment lie along a line defined by some linear equation? Picture a typical graph of a line. That's what we're going for here.

    
    // Vertex Shader
    
    varying vec2 v_uv;

    void main() {
        gl_Position = vec4(position, 1.0);
        v_uv = uv;
    }
 

    // Fragment Shader
    
    varying vec2 v_uv;

    float line(float a, float b, float line_width, float edge_thickness) {
        float half_line_width = line_width * 0.5;

        return smoothstep(a - half_line_width - edge_thickness, a - half_line_width, b) -
            smoothstep(a + half_line_width, a + half_line_width + edge_thickness, b);
    }

    void main() {
        vec3 color = vec3(1.0); // White
        float is_in_line = line(v_uv.x, v_uv.y, 0.02, 0.001);
        gl_FragColor = vec4(color, 1.0) * is_in_line;
    }
 
    

Now let's draw it angled the other way just to see another example.

    
    // Vertex Shader
    
    varying vec2 v_uv;

    void main() {
        gl_Position = vec4(position, 1.0);
        v_uv = uv;
    }
 

    // Fragment Shader
    
    varying vec2 v_uv;

    float line(float a, float b, float line_width, float edge_thickness) {
        float half_line_width = line_width * 0.5;

        return smoothstep(a - half_line_width - edge_thickness, a - half_line_width, b) -
            smoothstep(a + half_line_width, a + half_line_width + edge_thickness, b);
    }

    void main() {
        vec3 color = vec3(1.0); // White
        float is_in_line = line(1.0 - v_uv.x, v_uv.y, 0.02, 0.001);
        gl_FragColor = vec4(color, 1.0) * is_in_line;
    }
 
    

And now, just for fun, let's do a rotating line. Here there are 2 big changes to note. The first is that I'm passing in a new piece of data, a uniform that I'm calling u_time. A uniform is essentially just a piece of data that gets passed to each shader. I'm passing in the time via a little setup in the Three.js stuff, but don't worry too much about it. Just know that its a value that goes up over time and is getting passed to all my shaders.

Next, you'll note how I'm using that time value to calculate an angle of rotation before drawing the line. Check it out:

    
    // Vertex Shader
    
    varying vec2 v_uv;

    void main() {
        gl_Position = vec4(position, 1.0);
        v_uv = uv;
    }
 

    // Fragment Shader
    
    varying vec2 v_uv;
    uniform float u_time;

    float line(float a, float b, float line_width, float edge_thickness) {
        float half_line_width = line_width * 0.5;

        float rotated_a = (a - 0.5) * cos(u_time);
        float rotated_b = (b - 0.5) * sin(u_time);

        return smoothstep(rotated_a - half_line_width - edge_thickness, rotated_a - half_line_width, rotated_b) -
            smoothstep(rotated_a + half_line_width, rotated_a + half_line_width + edge_thickness, rotated_b);
    }

    void main() {
        vec3 color = vec3(1.0); // White
        float is_in_line = line(v_uv.x, v_uv.y, 0.02, 0.001);
        gl_FragColor = vec4(color, 1.0) * is_in_line;
    }
 
    

Now here the fancy bit that does this rotation is just x * cos(theta) and y * sin(theta). Hopefully you have some intuition about why I'm using sin/cos there, if not I would definitely brush up on some trig fundamentals. The early days of math can be pretty daunting when first getting into graphics stuff. I know it's a continuous challenge for me but one that I find really rewarding. If you're wondering what's up with the a - 0.5 stuff, I'm just shifting the center of my line to the middle of the screen, otherwise my origin point would be the bottom left corner.


Ask me questions on twitter or email me at ty@tytr.dev