DEV Community

Cover image for An Adventure in WebGL
eachampagne
eachampagne

Posted on

An Adventure in WebGL

Overview

WebGL is an API for drawing computer graphics on the GPU in a web browser, usually used for browser-based 3D graphics.

The general process of creating a WebGL application involves writing vertex and fragment shaders, linking them into a shader program, and passing data into that program. While the shaders themselves are written in GLSL, most of the process is done in JavaScript, which allows the use of dynamic data and considerable interactivity.

Demo

This post is not a tutorial. If you're looking to get started with WebGL, I recommend MDN's series of tutorials, which walk you through your first WebGL project. I learn better by experimentation, so once I got to the spinning cube part of the tutorial I branched off to try things on my own. This article summarizes my results.

Click and drag to rotate

The tutorial left me with a perpetually spinning cube, but I wanted more control. I wanted to be able to drag to rotate the cube to any orientation.

Rotation is controlled by a series of mat4.rotate() calls in drawScene(), which rotate modelViewMatrix about each of the three axes. This rotates the camera about the scene, creating the illusion that cube itself is rotating. To control the rotation directly, I moved the definition, translation, and rotation of modelViewMatrix out of drawScene() and into my main file, then passed the transformed modelViewMatrix into drawScene(). Setting up event handlers to rotate the matrix while dragging is fairly standard, but determining how to rotate the matrix required some calculations.

I decided that, while dragging the mouse in a particular direction, I wanted the cube to rotate about the axis perpendicular both to the movement of the mouse and to the line of sight into the page. I can get the mouse's direction of motion by comparing its current position to its position the last time the mousemove event fired. Reversing the x and y components gives me the perpendicular vector in the xy-plane.

function handleMouseMove(event) {
  if (isRotating) {
    const deltaX = event.offsetX - x;
    const deltaY = event.offsetY - y;

    // reset x and y for next mousemove event
    x = event.offsetX;
    y = event.offsetY;

    const screenAxis = vec3.fromValues(deltaY, deltaX, 0);
    const magnitude = Math.sqrt(deltaX*deltaX + deltaY*deltaY);
    vec3.normalize(screenAxis, screenAxis);

    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

This gets me the desired axis of rotation projected onto the plane of the screen. When the program first starts, this will match the intended axis of rotation. However, once the scene has been rotated, the screen and scene axes will be misaligned. To compensate for the scene's rotation, we first retrieve the current rotation as a quaternion:

const rotationQuat = quat.create();
mat4.getRotation(rotationQuat, modelViewMatrix);
Enter fullscreen mode Exit fullscreen mode

(Quaternions are one of several ways to encode three-dimensional rotation. Fortunately, the matrix library takes care of the actual computations.)

We then transform the screen axis by the inverse of the rotation quaternion to "undo" the scene's rotation and find the axis that is projected onto the screen axis:

mat4.getRotation(rotationQuat, modelViewMatrix);
quat.invert(rotationQuat, rotationQuat);

const rotationAxis = vec3.create();
vec3.transformQuat(rotationAxis, screenAxis, rotationQuat);
Enter fullscreen mode Exit fullscreen mode

Finally we rotate modelViewMatrix about the rotation axis, proportionally to the mouse velocity:

mat4.rotate(
  modelViewMatrix,
  modelViewMatrix,
  magnitude / 100,
  rotationAxis
);
Enter fullscreen mode Exit fullscreen mode

The scene is redrawn with the new rotation, and we can turn our cube any way we like.

The full function to handle rotating the scene is as follows:

function handleMouseMove(event) {
  if (isRotating) {
    const deltaX = event.offsetX - x;
    const deltaY = event.offsetY - y;

    // reset x and y for next mousemove event
    x = event.offsetX;
    y = event.offsetY;

    const screenAxis = vec3.fromValues(deltaY, deltaX, 0);
    const magnitude = Math.sqrt(deltaX*deltaX + deltaY*deltaY);
    vec3.normalize(screenAxis, screenAxis);


    const rotationQuat = quat.create();
    mat4.getRotation(rotationQuat, modelViewMatrix);
    quat.invert(rotationQuat, rotationQuat);

    const rotationAxis = vec3.create();
    vec3.transformQuat(rotationAxis, screenAxis, rotationQuat);

    mat4.rotate(
      modelViewMatrix,
      modelViewMatrix,
      magnitude / 100,
      rotationAxis
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Programmatic Stars

Once I had control over my scene, I wanted to draw something more interesting than our cube. I've always been interested in space, so I decided to create a WebGL galaxy with scattered points as my stars.

Much of the scene setup can remain the same, including our click and drag functionality, but a few things need to change. Most immediately, we need to change from drawing gl.TRIANGLES to gl.POINTS in our gl.drawElements() call. That doesn't quite work on its own, because WebGL doesn't know how big to draw each point, so we need to add the line gl_PointSize = 1.0; to the vertex shader source (within the main function) to draw each point as one pixel.

Because we're drawing individual points rather than triangles, using an index buffer to reuse vertices in different triangles doesn't help us. So we can remove the index buffer and change gl.drawElements (which uses the index buffer) back to gl.drawArrays (which doesn't). (If you're following along, you need to undo many of the changes in the Creating 3D objects using WebGL step.)

We still need vertex and color buffers, but now we want to randomly distribute our points instead of declaring them all manually. I found it simplest to create arrays of points and colors first, then pass those arrays into initPositionBuffer and indexColorBuffer to reuse the existing logic for binding the buffers.

Now it's just a matter of creating the stars. I left them all white (although the final result would be prettier with some variation - some regions of the galaxy should have more blue stars, others more red), but I had some fun coming up with ways to distribute them.

I split the galaxy into its disk and its bulge. For the disk, I evenly distributed the stars along r and z, but for theta I needed them to cluster into spiral arms. I played around with Wolfram|Alpha a bit to get the equation right but ended up with:

// maxRadius - radius of the whole galaxy
// rateOfCurvature - controls how tight the spiral arms are
// smearFactor - controls how "wide" the spiral arms are
// thicknessRatio - how thick the galaxy is, as a function of its radius
// numArms - how many spiral arms
function distributeDisk(maxRadius, rateOfCurvature, smearFactor, thicknessRatio, numArms = 2) {
  const thickness = thicknessRatio * maxRadius; // thickness of disk relative to radius

  const r = Math.random() * maxRadius;

  const thetaSeed = Math.random();

  // 2 pi thetaSeed - spreads the stars evenly around the disk
  // sin(2 pi numArms * thetaSeed) / (numArms * smearFactor) - makes the stars group together in spiral arms
  // r * rateOfCurvature - changes the angles of peak and trough density as r increase - "bends" the arms
  const theta = 2 * Math.PI  * thetaSeed + Math.sin(thetaSeed * 2 * numArms * Math.PI) / (numArms * smearFactor) + r * rateOfCurvature;

  const z = Math.random() * thickness - (thickness / 2);

  return [r, theta, z];
}
Enter fullscreen mode Exit fullscreen mode

Not astrophysically accurate by any means, but clearly a spiral galaxy!

The bulge was simpler - just create a sphere, then compress it a bit along the z axis to make an ellipsoid.

// maxRadius: radius of the bulge (before compressing)
// compressionFactor (< 1): - multiplies the z-height of the bulge to make it an ellipsoid
function distributeBulge(maxRadius, compressionFactor) {
  const r = Math.random() * maxRadius;
  const theta = Math.random() * Math.PI;
  const phi = Math.random() * 2 * Math.PI;

  const [x, y, z] = sphericalToCartesian([r, theta, phi]);

  return [x, y, z * compressionFactor];
}
Enter fullscreen mode Exit fullscreen mode

My very own galaxy!

I left out a lot of the details (coordinate transformations, passing parameters around, etc.) in favor of getting to the most interesting details. I used 10000 stars, and while there's a noticeable pause while they're all created and assigned positions, once the initial setup is finished there's no lag while rotating the galaxy.

I also left myself a few parameters to tweak my results:

Rate of curvature 2:

Rate of curvature 4:

I don't consider myself particularly artistic, so it's exciting to be able to create (and customize) something like this!

Further Reading

The MDN WebGL tutorial - good for getting started quickly
glMatrix - the matrix library used in the MDN tutorials and my code snippets
WebGL Fundamentals - once you've finished the MDN tutorial, go here for deeper conceptual understanding
3Blue1Brown's Essence of Linear Algebra - at least some knowledge of linear algebra is important in computer graphics. I recommend starting here.
Wikipedia articles on rotation matrices, quaternions, and various other graphics programming topics

Cover image: a screenshot from my WebGL galaxy demo.

Top comments (0)