High-speed Light Trails in Three.js

A creative coding exploration into how to recreate a high-speed lights effect in real-time using Three.js.
LightTrails_featured

From our sponsor: Design every part of your website with the Divi Theme Builder.

Sometimes I tactically check Pinterest for inspiration and creative exploration. Although one could also call it chronic procrastinating, I always find captivating ideas for new WebGL projects. That’s the way I started my last water distortion effect.

Today’s tutorial is inspired by this alternative Akira poster. It has this beautiful traffic time lapse with infinite lights fading into the distance:

Akira

Based on this creative effect, I decided to re-create the poster vibe but make it real-time, infinite and also customizable. All in the comfort of your browser!

Through this article, we’ll use Three.js and learn how to:

  1. instantiate geometries to create thousands (up to millions) of lights
  2. make the lights move in an infinite loop
  3. create frame rate independent animations to keep them consistent on all devices
  4. and finally, create modular distortions to ease the creation of new distortions or changes to existing ones

It’s going to be an intermediate tutorial, and we’re going to skip over the basic Three.js setup. This tutorial assumes that you are familiar with the basics of Three.js.

Preparing the road and camera

To begin we’ll create a new Road class to encapsulate all the logic for our plane. It’s going to be a basic PlaneBufferGeometry with its height being the road’s length.

We want this plane to be flat on the ground and going further way. But Three.js creates a vertical plane at the center of the scene. We’ll rotate it on the x-axis to make it flat on the ground (y-axis).

We’ll also move it by half it’s length on the z-axis to position the start of the plane at the center of the scene.

We’re moving it on the z-axis because position translation happens after the rotation. While we set the plane’s length on the y-axis, after the rotation, the length is on the z-axis.

export class Road {
  constructor(webgl, options) {
    this.webgl = webgl;
    this.options = options;
  }
  init() {
    const options = this.options;
    const geometry = new THREE.PlaneBufferGeometry(
      options.width,
      options.length,
      20,
      200
    );
    const material = new THREE.ShaderMaterial({ 
       	fragmentShader, 
        vertexShader,
        uniforms: {
           uColor:  new THREE.Uniform(new THREE.Color(0x101012)) 
        }
    });
    const mesh = new THREE.Mesh(geometry, material);

    mesh.rotation.x = -Math.PI / 2;
    mesh.position.z = -options.length / 2;

    this.webgl.scene.add(mesh);
  }
}
const fragmentShader = `
    uniform vec3 uColor;
	void main(){
        gl_FragColor = vec4(uColor,1.);
    }
`;
const vertexShader = `
	void main(){
        vec3 transformed = position.xyz;
        gl_Position = projectionMatrix * modelViewMatrix * vec4(transformed.xyz, 1.);
	}
`

After rotating our plane, you’ll notice that it disappeared. It’s exactly lined up with the camera. We’ll have to move the camera a bit up the y-axis for a better shot of the plane.

We’ll also instantiate and initiate our plane and move it on the z-axis a bit to avoid any issues when we add the distortion later on:

class App {
	constructor(container, options){
		super(container);
		
        this.camera.position.z = -4;
        this.camera.position.y = 7;
        this.camera.position.x = 0;
        
        this.road = new Road(this, options);
	}
	init(){
        this.road.init();
        this.tick();
	}
}

If something is not working or looking right, zooming out the camera in the z-axis can help bring things into perspective.

Creating the lights

For the lights, we’ll create a CarLights class with a single tube geometry. We’ll use this single tube geometry as a base for all other lights.

All our tubes are going to have different lengths and radii. So, we’ll set the original tube’s length and radius to 1. Then, in the tube’s vertex shader, we’ll multiply the original length/radius by the desired values, resulting in the tube getting its final length and radius.

Three.js makes TubeGeometries using a Curve. To give it that length of 1, we’ll create the tube with a lineCurve3 with its endpoint at -1 in the z-axis.

import * as THREE from "three";
export class CarLights {
  constructor(webgl, options) {
    this.webgl = webgl;
    this.options = options;
  }
  init() {
      const options = this.options;
    let curve = new THREE.LineCurve3(
      new THREE.Vector3(0, 0, 0),
      new THREE.Vector3(0, 0, -1)
    );
    let baseGeometry = new THREE.TubeBufferGeometry(curve, 25, 1, 8, false);
    let material = new THREE.MeshBasicMaterial({ color: 0x545454 });
    let mesh = new THREE.Mesh(baseGeometry, material);
	
      this.mesh = mesh;
    this.webgl.scene.add(mesh);
  }
}

Instantiating the lights

Although some lights are longer or thicker than others, they all share the same geometry. Instead of creating a bunch of meshes for each light, and causing lots of draw calls, we can take advantage of instantiation.

Instantiation is the equivalent of telling WebGL “Hey buddy, render this SAME geometry X amount of times”. This process allows you to reduce the amount of draw calls to 1.

Although it’s the same result, rendering X objects, the process is very different. Let’s compare it with buying 50 chocolates at a store:

A draw call is the equivalent of going to the store, buying only one chocolate and then coming back. Then we repeat the process for all 50 chocolates. Paying for the chocolate (rendering) at the store is pretty fast, but going to the store and coming back (draw calls) takes a little bit of time. The more draw calls, the more trips to the store, the more time.

With instantiation, we’re going to the store and buying all 50 chocolates and coming back. You still have to go and come back from the store (draw call) one time. But you saved up those 49 extra trips.

A fun experiment to test this even further: Try to delete 50 different files from your computer, then try to delete just one file of equivalent size to all 50 combined. You’ll notice that even though it’s the same combined file size, the 50 files take more time to be deleted than the single file of equivalent size 😉

Coming back to the code: to instantiate we’ll copy our tubeGeometry over to an InstancedBufferGeometry. Then we’ll tell it how many instances we’ll need. In our case, it’s going to be a number multiplied by 2 because we want two lights per “car”.

Next we’ll have to use that instanced geometry to create our mesh.

class CarLights {
    ...
	init(){
        ...
        let baseGeometry = new THREE.TubeBufferGeometry(curve, 25, 1, 8, false);
        let instanced = new THREE.InstancedBufferGeometry().copy(geometry);
        instanced.maxInstancedCount = options.nPairs * 2;
        ...
        // Use "Instanced" instead of "geometry"
        var mesh = new THREE.Mesh(instanced, material);
    }
}

Although it looks the same, Three.js now rendered 100 tubes in the same position. To move them to their respective positions we’ll use an InstancedBufferAttribute.

While a regular BufferAttribute describes the base shape, for example, it’s position, uvs, and normals, an InstanceBufferAttribute describes each instance of the base shape. In our case, each instance is going to have a different aOffset and a different radius/length aMetrics.

When it’s time each instance passes through the vertex shader. WebGL is going to give us the values corresponding to each instance. Then we can position them using those values.

We’ll loop over all the light pairs and calculate their XYZ position:

  1. For the X-axis we’ll calculate the center of its lane. The width of the car, how separated the lights are, and a random offset.
  2. For its Y-axis, we’ll push it up by its radius to make sure it’s on top of the road.
  3. Finally, we’ll give it a random Z-offset based on the length of the road, putting some lights further away than others.

At the end of the loop, we’ll add the offset twice. Once per each light, with only the x-offset as a difference.

class CarLights {
    ...
    init(){
        ...
        let aOffset = [];

            let sectionWidth = options.roadWidth / options.roadSections;

            for (let i = 0; i < options.nPairs; i++) {
              let radius = 1.;
              // 1a. Get it's lane index
              // Instead of random, keep lights per lane consistent
              let section = i % 3;

              // 1b. Get its lane's centered position
              let sectionX =
                section * sectionWidth - options.roadWifth / 2 + sectionWidth / 2;
              let carWidth = 0.5 * sectionWidth;
              let offsetX = 0.5 * Math.random();

              let offsetY = radius * 1.3;

              aOffset.push(sectionX - carWidth / 2 + offsetX);
              aOffset.push(offsetY);
              aOffset.push(-offsetZ);

              aOffset.push(sectionX + carWidth / 2 + offsetX);
              aOffset.push(offsetY);
              aOffset.push(-offsetZ);
            }
        // Add the offset to the instanced geometry.
        instanced.addAttribute(
          "aOffset",
          new THREE.InstancedBufferAttribute(new Float32Array(aOffset), 3, false)
        );
        ...
    }
}

Now that we've added our aOffset attribute, let's go ahead and use it on a vertex shader like a regular bufferAttribute.

We'll replace our MeshBasicMaterial with a shaderMaterial and create a vertex shader where we'll add aOffset to the position:

class TailLights {
	init(){
		...
		const material = new THREE.ShaderMaterial({
			fragmentShader, 
            vertexShader,
            	uniforms: {
                    uColor: new THREE.Uniform(new THREE.Color('0xfafafa'))
                }
		})
		...
	}
}
const fragmentShader = `
uniform vec3 uColor;
  void main() {
      vec3 color = vec3(uColor);
      gl_FragColor = vec4(color,1.);
  }
`;

const vertexShader = `
attribute vec3 aOffset;
  void main() {
		vec3 transformed = position.xyz;

		// Keep them separated to make the next step easier!
	   transformed.z = transformed.z + aOffset.z;
        transformed.xy += aOffset.xy;
	
        vec4 mvPosition = modelViewMatrix * vec4(transformed,1.);
        gl_Position = projectionMatrix * mvPosition;
	}
`;


[https://codesandbox.io/s/infinite-lights-02-road-and-lights-coznb ]

Depending from where you look at the tubes, you'll notice that they might look odd. By default, Three.js' materials don't render the backside of faces side:THREE.FontSide.

While we could fix it by changing it to side: THREE.DoubleSide to render all sides, our tubes are going to be small and fast enough that you won't be able to notice the back faces aren't rendered. We can keep it like that for the sake of performance.

Giving tubes a different length and radius

Creating our tube with a length and radius of 1 was crucial for this section to work. Now we can set the radius and length of each instance only by multiplying on the vertex shader 1 * desiredRadius = desiredRadius.

Let's use the same loop to create a new instancedBufferAttribute called aMetrics. We'll store the length and radius of each instance here.

Remember that wee push to the array twice. One for each of the items in the pair.

class TailLights {
	...
	init(){
	...
	let aMetrics =[];
	for (let i = 0; i < totalLightsPairs; i++) {
     // We give it a minimum value to make sure the lights aren't too thin or short.
     // Give it some randomness but keep it over 0.1
      let radius = Math.random() * 0.1 + 0.1;
     // Give it some randomness but keep it over length *0.02
      let length =
        Math.random() * options.length * 0.08 + options.length * 0.02;
      
      aMetrics.push(radius);
      aMetrics.push(length);

      aMetrics.push(radius);
      aMetrics.push(length);
    }
    instanced.addAttribute(
      "aMetrics",
      new THREE.InstancedBufferAttribute(new Float32Array(aMetrics), 2, false)
    );
    ...
}

Note that we multiplied the position by aMetrics before adding any aOffset. This expands the tubes from their center, and then moves them to their position.

...
const vertexShader = `
attribute vec3 aOffset;
attribute vec2 aMetrics;
  void main() {
    vec3 transformed = position.xyz;

            float radius = aMetrics.r;
            float len = aMetrics.g;

            // 1. Set the radius and length
            transformed.xy *= radius; 
            transformed.z *= len;
		
    // 2. Then move the tubes
   transformed.z = transformed.z + aOffset.z;
   transformed.xy += aOffset.xy;

   vec4 mvPosition = modelViewMatrix * vec4(transformed,1.);
   gl_Position = projectionMatrix * mvPosition;
}
`;

Positioning the lights

We want to have two roads of lights coming from different directions. Let's create the second TailLights and move each to their respective position. To center them both, we'll move them by half the middle island's width and half the road's width.

We'll also give each light its color, and modify the material to use that instead:

class App {
    constructor(){
        this.leftLights  = new TailLights(this, options, 0xff102a);
        this.rightLights = new TailLights(this, options, 0xfafafa);
    }
	init(){
		...
		
        this.leftLights.init();
        this.leftLights.mesh.position.setX(
           -options.roadWidth / 2 - options.islandWidth / 2
        );
        this.rightLights.init();
        this.rightLights.mesh.position.setX(
           options.roadWidth / 2 + options.islandWidth / 2
        );

	}
}
class TailLights {
	constuctor(webgl, options, color){
		this.color = color;
		...
	}
        init(){
            ...
            const material = new THREE.ShaderMaterial({
                fragmentShader, 
                vertexShader,
                    uniforms: {
                        uColor: new THREE.Uniform(new THREE.Color(this.color))
                    }
            })
            ...
        }
}

Looking great! We can already start seeing how the project is coming together!

Moving and looping the lights

Because we created the tube's curve on the z-axis, moving the lights is only a matter of adding and subtracting from the z-axis. We'll use the elapsed time uTime because time is always moving and it's pretty consistent.

Let's begin with adding a uTime uniform and an update method. Then our App class can update the time on both our CarLights. And finally, we'll add time to the z-axis on the vertex shader:

class TailLights {
	init(){
		...
		cosnt material = new THREE.ShaderMaterial({
			fragmentShader, vertexShader,
            	uniforms: {
                    uColor: new THREE.Uniform(new THREE.Color(this.color)),
                    uTime: new THREE.Uniform(0),
                }
		})
		...
	}
        update(t){
            this.mesh.material.uniforms.uTime.value = t;
        }
}
const vertexShader = `
attribute vec3 aOffset;
attribute vec2 aMetrics;
  void main() {
    vec3 transformed = position.xyz;

    float radius = aMetrics.r;
    float len = aMetrics.g;
    transformed.xy *= radius; 
    transformed.z *= len;

            // 1. Add time, and it's position to make it move
            float zOffset = uTime + aOffset.z;
		
            // 2. Then place them in the correct position
            transformed.z += zOffset;

    transformed.xy += aOffset.xy;
	
    vec4 mvPosition = modelViewMatrix * vec4(transformed,1.);
    gl_Position = projectionMatrix * mvPosition;
	}
`;
class App {
  ...
  update(delta) {
    let time = this.clock.elapsedTime;
    this.leftLights.update(time);
    this.rightLights.update(time);
  }
}

It moves ultra-slow, but it moves!

Let's create a new uniform uSpeed and multiply it with uTime to make the animation go faster. Because each road has to go to a different side we'll also add it to the CarLights constructor to make it customizable.

class TailLights {
  constructor(webgl, options, color, speed) {
    ...
    this.speed = speed;
  }
	init(){
		...
		cosnt material = new THREE.ShaderMaterial({
			fragmentShader, vertexShader,
            	uniforms: {
                    uColor: new THREE.Uniform(new THREE.Color(this.color)),
                    uTime: new THREE.Uniform(0),
                    uSpeed: new THREE.Uniform(this.speed)
                }
		})
		...
	}
    ...
}
const vertexShader = `
attribute vec3 aOffset;
attribute vec2 aMetrics;
  void main() {
    vec3 transformed = position.xyz;

    // 1. Set the radius and length
    float radius = aMetrics.r;
    float len = aMetrics.g;
    transformed.xy *= radius; 
    transformed.z *= len;

    // 2. Add time, and it's position to make it move
        	float zOffset = uTime * uSpeed + aOffset.z;
			
    // 2. Then place them in the correct position
    transformed.z += zOffset;

    transformed.xy += aOffset.xy;
	
    vec4 mvPosition = modelViewMatrix * vec4(transformed,1.);
    gl_Position = projectionMatrix * mvPosition;
}
`;

Now that it's fast, let's make it loop.

We'll use the modulo operator mod to find the remainder of z-offset zOffset divided by the total road length uTravelLength. Getting only the remainder makes zOffset loop whenever it goes over uTravelLength.

Then, we'll subtract that from the z-axis and also add the length len to make it loop outside of the camera's view. And that's looping tubes!

Let's go ahead and add the uTravelLength uniform to our material:

class TailLights {
	init(){
		...
		cosnt material = new THREE.ShaderMaterial({
			fragmentShader, vertexShader,
            	uniforms: {
                    uColor: new THREE.Uniform(new THREE.Color(this.color)),
                    uTime: new THREE.Uniform(0),
                    uSpeed: new THREE.Uniform(this.speed)
                    uTime: new THREE.Uniform(0),
                }
		})
		...
	}
}

And let's modify the vertex shaders zOffset to make it loop:

const vertexShader = `
attribute vec3 aOffset;
attribute vec2 aMetrics;
uniform float uTime;
uniform float uSpeed;
uniform float uTravelLength;
  void main() {
    vec3 transformed = position.xyz;
    
    float radius = aMetrics.r;
    float len = aMetrics.g;
    transformed.xy *= radius; 
    transformed.z *= len;

        float zOffset = uTime * uSpeed + aOffset.z;
        // 1. Mod by uTravelLength to make it loop whenever it goes over
        // 2. Add len to make it loop a little bit later
        zOffset = len - mod(zOffset , uTravelLength);

   // Keep them separated to make the next step easier!
   transformed.z = transformed.z +zOffset ;
   transformed.xy += aOffset.xy;

   vec4 mvPosition = modelViewMatrix * vec4(transformed,1.);
   gl_Position = projectionMatrix * mvPosition;
}
`;

If you have a hawk's eye for faulty code, you'll noticed the loop isn't perfect. Behind the camera, the tubes go beyond the road's limits (push the camera back to see it in action). But for our use case, it does the job. Imperfect details outside of the camera don't matter.

Going faster and beyond

When holding left click we want our scene to go Speed Racer mode. Faster, and with a wider camera view.

Because the tube's speed is based on time, we'll add an extra offset to time whenever the left click is down. To make this transition extra smooth, we'll use linear interpolation (lerp) for the speedUp variable.

Note: We keep the timeOffset separate from the actual clock's time. Mutating the clock's time is never a good idea.

function lerp(current, target, speed = 0.1, limit = 0.001) {
  let change = (target - current) * speed;
  if (Math.abs(change) < limit) {
    change = target - current;
  }
  return change;
}

class App {
	constructor(){
		...
		this.speedUpTarget = 0.;
		this.speedUp = 0;
		this.timeOffset = 0;
		this.onMouseDown = this.onMouseDown.bind(this);
		this.onMouseUp = this.onMouseUp.bind(this);
	}
	init(){
		...
        this.container.addEventListener("mousedown", this.onMouseDown);
        this.container.addEventListener("mouseup", this.onMouseUp);
        this.container.addEventListener("mouseout", this.onMouseUp);
	}
  onMouseDown(ev) {
    this.speedUpTarget = 0.1;
  }
  onMouseUp(ev) {
    this.speedUpTarget = 0;
  }
  update(delta){
  	
      // Frame-dependent
    this.speedup += lerp(
      this.speedUp,
      this.speedUpTarget,
        // 10% each frame
      0.1,
      0.00001
    );
      // Also frame-dependent
    this.timeOffset += this.speedUp;
      
      
    let time = this.clock.elapsedTime + this.timeOffset;
    ...
    
  }
}

This is a totally functional and valid animation for our super speed mode; after all, it works. But it'll work differently depending on your Frames Per Second (FPS).

Frame rate independent speed up

The issue with the code above is that every frame we are adding a flat amount to the speed. This animation's speed depends on the frame rate.

It means if your frame rate suddenly becomes lower, or your frame rate was low to begin with, the animation is going to become slower as well. And if your frame rate is higher, the animation is going to speed up.

Resulting in the animations running faster or slower or depending on how many frames per second your computer can achieve, a frame rate dependent animation that takes 2 seconds at 30ps, takes 1 second at 60fps.

Our goal is to animate things using real-time. For all computers, the animations should always take X amount of seconds.

Looking back at our code, we have two animations that are frame rate dependent:

  • the speedUp's linear interpolation by 0.1 each frame
  • adding speedUp to timeOffset each frame

Adding speedUp to timeOffset is a linear process; it only depends on the speedup variable. So, we can make it frame rate independent by multiplying it by how many seconds have passed since the last frame (delta).

This one-line change makes the addition one this.speedUp per second. You might need to bump up the speed since the change makes the addition happen through a whole second.

class App {
	update(delta){
		...
         this.timeOffset += this.speedup * delta;		
		...
	} 
 }

Making the speedUp linear interpolation frame rate independent requires a little bit more math.

In the previous case, adding this.speedUp was a linear process, only dependent on the speedUp value. To make it frame rate independent we used another linear process: multiplying it by delta.

In the case of linear interpolation (lerp), we are trying to move towards the target 10% of the difference each time. This is not a linear process but an exponential process. To make it frame rate independent, we need another exponential process that involves delta.

We'll use the functions found in this article about making lerp frame rate independent.

Instead of moving towards the target 10% each frame, we'll move towards the target based on an exponential function based on time delta instead.

let coefficient = 0.1;
let lerpT = Math.exp(-coefficient * delta); 
this.speedup += lerp(
      this.speedup,
      this.speedupTarget,
      lerpT,
      0.00001
    );

This modification completely changes how our coefficient works. Now, a coefficient of 1.0 moves halfway to the target each second.

If we want to use our old coefficients 0.1 that we know already works fine for 60fps, we can convert the old coefficient into the new ones like this:

let coefficient = -60*Math.log2(1 - 0.1);

Plot twist: Math is actually hard. Although there are some great links out there explaining how all the math makes sense, some of it still flies over my head. If you know more about the theory of why all of this works. Feel free to reach out or type it in the comments. I would love to have a chat!

Repeat the process for the Camera's Field Of View camera.fov. And we also get a frame rate independent animation for the fov. We'll reuse the same lerpT to make it easier.

class App {
	constructor(){
		...
        this.fovTarget = 90;
        ...
	}
  onMouseDown(ev) {
    this.fovTarget = 140;
    ...
  }
  onMouseUp(ev) {
    this.fovTarget = 90;
     ...
  }
  update(delta){
      ...
    let fovChange = lerp(this.camera.fov, this.fovTarget, lerpT );
    if (fovChange !== 0) {
      this.camera.fov += fovChange * delta * 6.;
      this.camera.updateProjectionMatrix();
    }
    ...
    
  }
}

Note: Don't forget to update its transformation matrix after you are done with the changes or it won't update in the GPU.

Modularized distortion

The distortion of each object happens on the vertex shader. And as you can see, all objects share the same distortion. But GLSL doesn't have a module system unless you add something like glslify. If you want to reuse and swap pieces of GLSL code, you have to create that system yourself with JavaScript.

Alternatively, if you have only one or two shaders that need distortion, you can always hard code the distortion GLSL code on each mesh's shader. Then, update each one every time you make a change to the distortion. But try to keep track of updating more than two shaders and you start going insane quickly.

In my case, I chose to keep my sanity and create my own little system. This way I could create multiple distortions and play around with the values for the different demos.

Each distortion is an object with three main properties:

  1. distortion_uniforms: The uniforms this distortion is going to need. Each mesh takes care of adding these into their material.
  2. distortion_chunk: The GLSL code that exposes getDistortion function for the shaders that implement it. getDistortion receives a normalized value progress indicating how far into the road is the point. It returns the distortion of that specific position.
  3. (Optional) getJS: The GLSL code ported to JavaScript. This is useful for creating JS interactions following the curve. Like the camera rotating to face the road as we move along.
const distortion_uniforms = {
  uDistortionX: new THREE.Uniform(new THREE.Vector2(80, 3)),
  uDistortionY: new THREE.Uniform(new THREE.Vector2(-40, 2.5))
};

const distortion_vertex = `
#define PI 3.14159265358979
  uniform vec2 uDistortionX;
  uniform vec2 uDistortionY;

    float nsin(float val){
    return sin(val) * 0.5+0.5;
    }
  vec3 getDistortion(float progress){
        progress = clamp(progress, 0.,1.);
        float xAmp = uDistortionX.r;
        float xFreq = uDistortionX.g;
        float yAmp = uDistortionY.r;
        float yFreq = uDistortionY.g;
        return vec3( 
            xAmp * nsin(progress* PI * xFreq   - PI / 2. ) ,
            yAmp * nsin(progress * PI *yFreq - PI / 2.  ) ,
            0.
        );
    }
`;

const myCustomDistortion = {
    uniforms: distortion_uniforms,
    getDistortion: distortion_vertex,
}

Then, you pass the distortion object as a property in the options given when instantiating the main App class like so:

const myApp = new App(
	container, 
	{
        ... // Bunch of other options
		distortion: myCustomDistortion,
        ...
    }
)
...

From here each object can take the distortion from the options and use it as it needs.

Both, the CarLights and Road classes are going to add distortion.uniforms to their material and modify their shader using Three.js' onBeforeCompile:

const material = new THREE.ShaderMaterial({
	...
	uniforms: Object.assign(
		{...}, // The original uniforms of this object
		options.uniforms
	)
})

material.onBeforeCompile = shader => {
  shader.vertexShader = shader.vertexShader.replace(
    "#include ",
    options.distortion.getDistortion
  );
};

Before Three.js sends our shaders to webGL it checks it's custom GLSL to inject any ShaderChunks your shader needs. onBeforeCompile is a function that happens before Three.js compiles your shader into valid GLSL code. Making it easy to extend any built-in materials.

In our case, we'll use onBeforeCompile to inject our distortion's code. Only to avoid the hassle of injecting it another way.

As it stands now, we aren't injecting any code. We first need to add #include <getDistortion_vertex> to our shaders.

In our CarLights vertex shader we need to map its z-position as its distortion progress. And we'll add the distortion after all other math, right at the end:

// Car Lights Vertex shader
const vertexShader = `
attribute vec3 aOffset;
attribute vec2 aMetrics;
uniform float uTime;
uniform float uSpeed;
uniform float uTravelLength;
#include 
  void main() {
	...
        

		// Map z-position to progress: A range of 0 to 1.
        float progress = abs(transformed.z / uTravelLength);
        transformed.xyz += getDistortion(progress);

	
        vec4 mvPosition = modelViewMatrix * vec4(transformed,1.);
        gl_Position = projectionMatrix * mvPosition;
	}
`;

In our Road class, although we see it flat going towards negative-z because we rotated it, this mesh rotation happens after the vertex shader. In the eyes of our shader, our plane is still vertical y-axis and placed in the center of the scene.

To get the correct distortion, we need to map the y-axis as progress. First, we'll un-center it uTravelLength /2., and then we'll normalize it.

Also, instead of adding the y-distortion to the y-axis, we'll add it to the z-axis instead. Remember, in the vertex shader, the rotation hasn't happened yet.

// Road Vertex shader
const vertexShader = `
uniform float uTravelLength;
#include 
	void main(){
        vec3 transformed = position.xyz;
        
	// Normalize progress to a range of 0 to 1
    float progress = (transformed.y + uTravelLength / 2.) / uTravelLength;
    vec3 distortion  = getDistortion(progress);
    transformed.x += distortion.x;
	// z-axis is becomes the y-axis after mesh rotation. 
    transformed.z += distortion.y;
    gl_Position = projectionMatrix * modelViewMatrix * vec4(transformed.xyz, 1.);
	}
`;

An there you have the final result for this tutorial!

Finishing touches

There are a few ways you can expand and better sell the effect of an infinite road in the middle of the night. Like creating more interesting curves and fading the objects into the background with some fog effect to make the lights seem like they are glowing.

Final Thoughts

I find that re-creating things from outside of the web and simply doing some creative coding, opens me up to a wider range of interesting ideas.

In this tutorial, we learned how to instantiate geometries, create frame rate independent animations and modulized distortions. And we brought it all together to re-create and put some motion into this awesome poster!

Hopefully, you've also liked working through this tutorial! Let me know what you think in the comments and feel free to reach out to me!

Tagged with:

Daniel Velasquez

Daniel is a freelance front-end developer with a passion for interactive experiences. He enjoys deconstructing interesting websites and sharing ideas. Owns a diary and is writing about you right now.

https://velasquezdaniel.now.sh/

Stay up to date with the latest web design and development news and relevant updates from Codrops.

CSS Reference

Learn about all important CSS properties from the basics with our extensive and easy-to-read CSS Reference.

It doesn't matter if you are a beginner or intermediate, start learning CSS now.

Feedback 2

Leave a comment
  1. Great effect and tutorial. I’m new to three.js and really don’t know anything about it. I wanted to update the background color of the canvas and found out how, so I thought I’d add it here:

    In index.html I added a property to the options.colors object:

    colors: {
    backgroundCanvas: 0x232323,

    }

    Then in InfiniteLights.js I added this line before the renderer is appended to the container:

    this.renderer.setClearColor(options.colors.backgroundCanvas);

    Hopefully this helps someone out there looking to do the same.

Follow this discussion

Leave a Comment