🎅

【Three.js × GLSL】Glitch Effects : Starting from the Basics 🔰

に公開

This article is the December 19th post of the Money Forward Engineers Advent Calendar 2025 🎅
This article is also available in Japanese: Read the Japanese version of this article

Introduction

In this article, I explain how to implement glitch effects using shaders, while covering the basics of Three.js and GLSL.

The images used in this article are borrowed from unsplash.

Although this topic is not directly related to my current work, I often worked on animation in my previous job and have long been interested in creative coding.
One day, I heard from an engineer in the company that they shared a similar interest, which inspired me to write this article 🎅

Intended Audience

  • Those who understand the basics of Three.js but are beginners to shaders
  • Those who have just started learning shaders and want to deepen their understanding by creating simple effects
  • Those who are interested in glitch effects and want to try them while understanding how they work

What This Article Does Not Cover

This article does not go into detail about introductory shader concepts.
If you are new to shaders, I think it will be easier to follow if you have a basic understanding of the following topics:

  • What shaders are
  • The difference in roles between vertex shaders and fragment shaders
  • The basic structure of the rendering pipeline
  • Coordinate transformations (model space, world space, view space, clip space, etc.)
  • What UV coordinates are
  • Basic GLSL syntax

I studied shaders through a lot of trial and error, but the resource that I personally found the easiest to understand was three.js journey.
It is a paid course, but it provides well-organized introductory content and practical knowledge that is very helpful for learning shaders.

Notes

There are many different ways to implement glitch effects.
In this article, I introduce just one example approach.

I’m sure there are better implementations out there, so I hope you’ll treat this article as a reference to help you take the first step.

Also, before getting into glitch effects, this article spends a bit more time on the warm-up section.
I believe that understanding this part will make it much easier to work on implementing glitch effects later on.

What Is a Glitch

Before starting the implementation, let’s first briefly go over what a “glitch” is.

Glitch screenshot

A glitch is a visual effect created by intentionally introducing distortion or noise.
It can be used to evoke atmospheres such as retro, cool, cyberpunk, or horror.

Before diving into implementing glitch effects, one thing I personally find important is understanding that there are multiple types of glitches.
Even effects that look complex at first glance are often created by combining several simple effects.

As a starting point, I recommend watching this YouTube video to get a rough overview of different types of glitches.
Although it’s a video for After Effects, it’s very helpful for understanding the underlying ideas behind these visual expressions.

The video introduces nine different glitch effects, but after writing this article, I realized that explaining all of them would result in an overwhelming amount of content.

So in this article, I focus on the following three effects:

  • Base Scan Line
  • Chromatic Aberration
  • Deform Line

That said, once you’re able to implement these, you should be able to create other glitch effects by applying the same ideas with a bit of extra knowledge—so I encourage you to give it a try 💪
I’ve also included an advanced CodePen example at the end of the article.

Preparation

1. Set Up the Development Environment

I’ve prepared a simple development environment.
A single plane with a texture applied is placed to fill the entire screen.

I’ll explain only the key points of the setup 🙏

Camera

js
this.camera = new THREE.OrthographicCamera(
  ...省略
);

Since we are not using any 3D-style effects in this example, we use an OrthographicCamera.

It is also possible to implement this with a PerspectiveCamera. In that case, you need to align window coordinates with WebGL coordinates. For a concrete approach, this Qiita article is a helpful reference.
By using this method, you can align DOM elements and 3D objects at the pixel level, which is a useful concept to know when creating expressions that combine HTML and three.js.

TIME_SCALE

js
const TIME_SCALE = 0.1; 
...
this.uniforms.uTime.value += delta * TIME_SCALE;

uTime represents the elapsed time passed to the shader as a uniform.
If the raw value is used as-is, the animation becomes too fast, so a factor called TIME_SCALE is used to control the speed at which time progresses. The value 0.1 is just an arbitrary choice.

By changing the value of TIME_SCALE, you can easily adjust the overall speed of the animation.

Sampling Texture Colors in a Shader

fragment shader
void main() {
  vec3 color = texture2D(uTexture, vUv).rgb;
  gl_FragColor = vec4(color, 1.0);
}

texture2D is a function used to sample color from an image (texture).
By retrieving the color at the position specified by vUv and assigning the result to gl_FragColor, the color is rendered on the screen for each pixel.

2. Make the Texture Behave Like background-image: cover;

Right now, the texture is stretched to match the window size.
To achieve behavior similar to CSS background-image: cover;, we’ll fix this issue.

In short, we compare the aspect ratio of the viewport and the image, determine which side overflows, and then scale the UVs around the center (0.5, 0.5).
This makes the shorter side fit the screen, while the longer side is cropped (trimmed) from the center—just like cover.

Since this idea can be hard to visualize from text alone, the diagram in this YouTube Shorts video makes it easier to understand.

Now, using the exact same idea from the video, let’s add updateCoverScale().

js
async loadTexture() {
 ...
 
 this.createMesh();
+ this.updateCoverScale();
 this.animate();
}

...

+updateCoverScale() {
+ if (!this.texture.image) return;

+ const w = window.innerWidth;
+ const h = window.innerHeight;
+ const viewportAspect = w / h;

+ const imageAspect = this.texture.image.width / this.texture.image.height;

+ let scaleX = 1.0;
+ let scaleY = 1.0;

+ if (imageAspect > viewportAspect) {
+   // Image is wider than canvas → fit by height, crop horizontally
+   scaleX = viewportAspect / imageAspect;
+   scaleY = 1.0;
+ } else {
+   // Image is taller than canvas → fit by width, crop vertically
+   scaleX = 1.0;
+   scaleY = imageAspect / viewportAspect;
+ }

+ // Pass resolution and aspect correction to shader
+ this.uniforms.uResolution.value.set(w, h, scaleX, scaleY);
+}

...

setupEvents() {
 window.addEventListener("resize", () => {
   ...

   if (this.mesh) {
     ...
+     this.updateCoverScale();
   }
 });
}

Now that we’ve created uResolution, we’ll pass it to the fragment shader.

js
createMesh() {
 ...
 this.uniforms = {
   uTime: { value: 0 },
+  uResolution: { value: new THREE.Vector4() },
   uTexture: { value: this.texture },
 };
 ...
}
fragment shader
uniform sampler2D uTexture;
+ uniform vec4 uResolution;

varying vec2 vUv;

void main() {
+  vec2 newUv = (vUv - vec2(0.5)) * uResolution.zw + vec2(0.5);

-  vec3 color = texture2D(uTexture, vUv).rgb;
+  vec3 color = texture2D(uTexture, newUv).rgb;
  gl_FragColor = vec4(color, 1.0);
}

As long as the underlying idea is the same, I think any approach is fine.
Another method you’ll often see is explained in this Qiita article, so feel free to use it as a reference.

Now the image will no longer stretch to match the window size!
I’ll paste the code up to this point below.

HTML
HTML
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Glitch Effect</title>
    <link rel="stylesheet" href="./style.css" />
  </head>
  <body>
    <canvas></canvas>
    <script type="module" src="./script.js"></script>
  </body>
</html>
CSS
CSS
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  margin: 0;
  padding: 0;
  overflow: hidden;
  width: 100vw;
  height: 100%;
}

canvas {
  display: block;
  width: 100vw;
  height: 100%;
}

JS
js
import * as THREE from "three";
import vertexShader from "./shaders/vertex.glsl";
import fragmentShader from "./shaders/fragment.glsl";

const TIME_SCALE = 0.1; // speed of time

class App {
  /**
   * Initialize the application
   * Get canvas element and start initialization process
   */
  constructor() {
    this.canvas = document.querySelector("canvas");
    this.init();
  }

  init() {
    this.setupScene();
    this.setupCamera();
    this.setupRenderer();
    this.loadTexture();
    this.setupEvents();
  }

  /**
   * Set up scene and clock
   */
  setupScene() {
    this.scene = new THREE.Scene();
    this.clock = new THREE.Clock();
  }

  /**
   * Set up orthographic camera
   */
  setupCamera() {
    const width = window.innerWidth;
    const height = window.innerHeight;

    this.camera = new THREE.OrthographicCamera(
      -width / 2,
      width / 2,
      height / 2,
      -height / 2,
      0.1,
      1000
    );
    this.camera.position.z = 1;
  }

  /**
   * Set up WebGL renderer
   */
  setupRenderer() {
    this.renderer = new THREE.WebGLRenderer({
      canvas: this.canvas,
      antialias: true,
    });
    this.renderer.setSize(window.innerWidth, window.innerHeight);
    this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  }

  /**
   * Load texture asynchronously
   * After loading, create mesh and start animation
   */
  async loadTexture() {
    const loader = new THREE.TextureLoader();
    this.texture = await new Promise((resolve) => {
      const tex = loader.load("/1.webp", () => {
        resolve(tex);
      });
    });

    this.createMesh();
    this.updateCoverScale();
    this.animate();
  }

  /**
   * Set up geometry, uniforms, and custom shader material
   */
  createMesh() {
    const geometry = new THREE.PlaneGeometry(
      window.innerWidth,
      window.innerHeight
    );

    this.uniforms = {
      uTime: { value: 0 },
      uResolution: { value: new THREE.Vector4() },
      uTexture: { value: this.texture },
    };

    const material = new THREE.RawShaderMaterial({
      vertexShader,
      fragmentShader,
      uniforms: this.uniforms,
    });

    this.mesh = new THREE.Mesh(geometry, material);
    this.scene.add(this.mesh);
  }

  /**
   * Update scale to cover the viewport by texture
   */
  updateCoverScale() {
    if (!this.texture.image) return;

    const w = window.innerWidth;
    const h = window.innerHeight;
    const viewportAspect = w / h;

    const imageAspect = this.texture.image.width / this.texture.image.height;

    let scaleX = 1.0;
    let scaleY = 1.0;

    if (imageAspect > viewportAspect) {
      // Image is wider than canvas → fit by height, crop horizontally
      scaleX = viewportAspect / imageAspect;
      scaleY = 1.0;
    } else {
      // Image is taller than canvas → fit by width, crop vertically
      scaleX = 1.0;
      scaleY = imageAspect / viewportAspect;
    }

    // Pass resolution and aspect correction to shader
    this.uniforms.uResolution.value.set(w, h, scaleX, scaleY);
  }

  /**
   * Animation loop
   * Update time uniform and render the scene
   */
  animate() {
    requestAnimationFrame(() => this.animate());

    const delta = this.clock.getDelta();
    this.uniforms.uTime.value += delta * TIME_SCALE;

    this.renderer.render(this.scene, this.camera);
  }

  /**
   * Update camera frustum based on window size
   * Adjust orthographic camera boundaries and update projection matrix
   */
  updateCameraPosition(width, height) {
    this.camera.left = -width / 2;
    this.camera.right = width / 2;
    this.camera.top = height / 2;
    this.camera.bottom = -height / 2;
    this.camera.updateProjectionMatrix();
  }

  /**
   * Set up event listeners
   * Handle window resize: update camera, renderer, and mesh geometry
   */
  setupEvents() {
    window.addEventListener("resize", () => {
      const width = window.innerWidth;
      const height = window.innerHeight;

      this.updateCameraPosition(width, height);

      this.renderer.setSize(width, height);
      this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));

      if (this.mesh) {
        this.mesh.geometry.dispose();
        this.mesh.geometry = new THREE.PlaneGeometry(width, height);
        this.updateCoverScale();
      }
    });
  }
}

new App();
fragment shader
fragment shader
precision mediump float;

uniform float uTime;
uniform sampler2D uTexture;
uniform vec4 uResolution;

varying vec2 vUv;

void main() {
  // Correct UV coordinates for aspect ratio
  vec2 newUv = (vUv - vec2(0.5)) * uResolution.zw + vec2(0.5);

  // get color from texture
  vec3 color = texture2D(uTexture, newUv).rgb;

  gl_FragColor = vec4(color, 1.0);
}

Warm-up

As a warm-up, we’ll create a few simple expressions to build intuition. (7 in total)

To make the behavior easier to understand, we’ll temporarily avoid using the texture and display only a white plane.
We’ll also use the UV coordinates before applying any aspect-ratio correction.

fragment shader
// vec2 newUv = (vUv - vec2(0.5)) * uResolution.zw + vec2(0.5);
vec2 newUv = vUv;

// vec3 color = texture2D(uTexture, newUv).rgb;
vec3 color = vec3(1.0, 1.0, 1.0);

1. Make the Plane Gray

Gray Plane

fragment shader
vec3 color = vec3(0.5, 0.5, 0.5); // Gray

First, let’s make the plane gray.
When the same value is assigned to the R, G, and B channels, the result is grayscale.

A light gray plane

fragment shader
vec3 color = vec3(0.8, 0.8, 0.8); // light gray

If you increase the values, such as using vec3(0.8, 0.8, 0.8), the color gets closer to vec3(1.0, 1.0, 1.0) (white), resulting in a brighter gray.
In other words, you can control the brightness by adjusting the numeric values.

2. Create a Black-to-White Gradient

We’ll create a gradient that is black at the bottom and gradually becomes white toward the top.

A plane with a black-to-white gradient from bottom to top

fragment shader
vec3 color = vec3(1.0, 1.0, 1.0) * newUv.y;

We make use of the UV coordinates.
Since newUv.y is 0.0 at the bottom of the screen and 1.0 at the top, using this value directly as brightness creates a gradient that becomes brighter toward the top.

3. Gradient Toward the Center from Both Sides

This is a gradient where the center is the brightest.
It’s easier to understand this gradient if you break the idea into two parts.

A plane with a white gradient toward the center from both sides

First, as shown in the image below, let’s set aside the top half for now and focus on making the area brighten from the bottom toward the center.

A plane that becomes white from the bottom toward the center

fragment shader
float gradation = 1.0;
gradation *= smoothstep(0.0, 0.5, newUv.y); // Gradient from the bottom toward the center

vec3 color = vec3(1.0, 1.0, 1.0) * gradation; 

Here, the smoothstep function comes into play.

The key point of smoothstep is that its return value always stays within the range 0.0–1.0.

In this case, since we are using smoothstep(0.0, 0.5, newUv.y):

  • When y ≤ 0.0 → 0.0
  • When 0.0 < y < 0.5 → a smoothly changing value between 0.0 and 1.0
  • When y ≥ 0.5 → 1.0

As a result, the area up to 0.5 (the center of the screen) becomes a gradient, and everything above that turns white.

Next, let’s do the same thing on the opposite side.
For now, we’ll ignore the bottom half and focus on making the area brighten from the top toward the center.

A plane that becomes white from the top toward the center

fragment shader
gradation *= smoothstep(1.0, 0.5, newUv.y); // gradient from the top toward the center

The expression for the opposite side looks like this.

In this case, it returns:

  • When y ≤ 0.5 → 1.0
  • When 0.5 < y < 1.0 → a value that smoothly changes from 1.0 to 0.0
  • When y ≥ 1.0 → 0.0

Finally, by multiplying the two values together, you get a gradient that fades toward the center from both sides.

fragment shader
float gradation = 1.0;
gradation *= smoothstep(0.0, 0.5, newUv.y); // gradient from the bottom toward the center
gradation *= smoothstep(1.0, 0.5, newUv.y); // gradient from the top toward the center

vec3 color = vec3(1.0, 1.0, 1.0) * gradation;
gl_FragColor = vec4(color, 1.0);

4. Two Colors: Black and White

Instead of a gradient, we’ll create a crisp black-and-white border pattern.

A plane with the top half white and the bottom half black

fragment shader
float border = step(0.5, newUv.y); // returns either 0.0 or 1.0
vec3 color = vec3(1.0, 1.0, 1.0) * border;

This can be achieved using the step function.

The step function does not return smooth values—instead, it returns only 0.0 or 1.0.

In this example, since we use step(0.5, newUv.y):

  • When y < 0.5 (the bottom half of the screen) → 0.0
  • When y ≥ 0.5 (the top half of the screen) → 1.0

As a result, the bottom half of the screen becomes black, and the top half becomes white.

5. Two Colors: Gray and Black

A plane composed of two colors: gray and black

fragment shader
float border = step(0.5, newUv.y) * 0.5; // turn the white areas into gray
vec3 color = vec3(1.0, 1.0, 1.0) * border;

We multiply by 0.5 because we want the areas that were previously output as white (1.0) to be rendered as gray instead.

6. Increase the Number of Lines (Create Repetition)

Let’s think about how to increase the number of lines, as shown below.
At first glance it looks like four lines, but if you consider “gray + black” as one set, it actually consists of two sets.
A striped plane made up of gray and black

irst, let’s see what happens when we scale the UV coordinates.
We’ll try doubling only the y-coordinate.

A plane with vertical stripes of alternating gray and black

fragment shader
+ newUv.y *= 2.0; // double the value
  float border = step(0.5, newUv.y) * 0.5; // gray + black
  vec3 color = vec3(1.0, 1.0, 1.0) * border;

  gl_FragColor = vec4(color, 1.0);

Since it’s hard to see what’s happening, I’ve outlined the area with a pink frame as shown in the image below.
In the original state (Warm-up 5), the pattern ends around the middle of the screen, and the area above the center is stretched out as solid white.

A diagram showing only the bottom half being rendered, with the top half stretched in white and highlighted in pink

However, it looks like if the result from the bottom half were directly repeated in the top half, we would end up with two sets (= four stripes).
In other words, it seems we just need to repeat the bottom half once more.

A vertically striped plane with alternating gray and black bands

fragment shader
  newUv.y *= 2.0; // double the value
+ newUv.y = fract(newUv.y); // extract only the fractional part
  float border = step(0.5, newUv.y) * 0.5; // gray + black
  vec3 color = vec3(1.0, 1.0, 1.0) * border;

  gl_FragColor = vec4(color, 1.0);

This can be achieved using the fract function.

For example:

  • When x = 1.3 → 0.3
  • When x = 1.9 → 0.9
  • When x = 2.9 → 0.9

When you multiply newUv.y by 2, the value range becomes 0.0–2.0. By applying fract, you can bring it back into the 0.0–1.0 range. This process causes the same pattern of values to repeat.

By the way, if you multiply newUv.y by 100, the pattern will repeat 100 times vertically.

A border pattern repeated 100 times vertically

fragment shader
newUv.y *= 100.0; // repeat 100 times along the y-axis

7. Create Regions with Different Colors

Next, let’s consider how to assign a different color to each line, like this:
A border pattern where each vertical line is assigned a different grayscale value

Intuitively, it seems like this would work if we divide the area into five horizontal sections and make it so that the same value is returned within each section. (See the illustration below.)

An illustration showing the screen divided into five horizontal sections, with the same value assigned to each region

First, let’s try writing the code like this.

fragment shader
newUv.y *= 5.0; // multiply uv.y by 5
newUv.y = floor(newUv.y); // create five regions that each share the same value

As explained in Warm-up 6, this is what happens when we multiply the UV coordinates by 5.
Here, the floor function comes into play.

In this case, the function returns values like:

  • When newUv.y = 1.0 → 1.0
  • When newUv.y = 1.3 → 1.0
  • When newUv.y = 2.9 → 2.0
  • When newUv.y = 3.1 → 3.0
  • When newUv.y = 3.4 → 3.0

As you can see, one key characteristic of floor is that it returns the same value within a certain range.

In this example, since newUv.y is multiplied by 5, the return value of floor(newUv.y) becomes one of the fixed values
0, 1, 2, 3, 4.

By using these values as identifiers for each region, we can assign the same value to each vertically divided section.
However, these values are still too large and not convenient to use directly as colors, so we need to convert them into a range between 0.0 and 1.0.

In addition, instead of using sequential values like 0.1, 0.2, 0.3, we can use pseudo-random values such as 0.6, 0.2, or 0.8 to produce more irregular-looking colors.

To do that, we’ll create a random1d function.

fragment shader
+ // Returns a pseudo-random value in the range 0.0–1.0
+ // Reference:
+ // https://thebookofshaders.com/10/
+ // https://gist.github.com/patriciogonzalezvivo/670c22f3966e662d2f83
+ float random1d(float x) {
+   return fract(sin(x) * 43758.5453123);
+ }

void main() {
  vec2 newUv = vUv;
  
  newUv.y *= 5.0; // multiply uv.y by 5
  newUv.y = floor(newUv.y); // create five regions that each share the same value
+ float border = random1d(newUv.y); // return a pseudo-random value between 0.0 and 1.0
+ vec3 color = vec3(1.0, 1.0, 1.0) * border;
  
  gl_FragColor = vec4(color, 1.0);
}

The random1d function is a pseudo-random function that always returns the same value in the range 0.0–1.0 for a given input.
I found the explanation of how this kind of random generation works in The Book of Shaders particularly easy to understand. In addition, many custom random and noise functions have been created by different people and are widely shared online (see this reference).

Because of this, when you pass the result of floor(newUv.y) as the input—as in this example—you get a different value for each region.

For example, it may return values like:

  • When newUv.y = 0.0 → 0.3
  • When newUv.y = 1.0 → 0.6
  • When newUv.y = 2.0 → 0.5
  • When newUv.y = 3.0 → 0.2
  • When newUv.y = 4.0 → 0.9

(The numbers above are just illustrative examples.)

These values can then be used directly as colors.

Creating the Glitch Effects

Now that the warm-up is done, it’s finally time to create the glitch effects.

1. Scan Line

This effect overlays many thin horizontal lines on top of the image. Let’s create it.

Plane with the Scan Line effect applied

To make the behavior easier to see, we’ll temporarily hide the texture and display only a white plane.

fragment shader
void main() {
  vec2 newUv = (vUv - vec2(0.5)) * uResolution.zw + vec2(0.5);
  // vec3 color = texture2D(uTexture, newUv).rgb;
  vec3 color = vec3(1.0, 1.0, 1.0);

  gl_FragColor = vec4(color, 1.0);
}

As explained in Warm-up 6, we can create a large number of stripes using the same approach.
This time, instead of a crisp black-and-white look, we want a slightly softer impression, so we’ll use the smoothstep function from Warm-up 3.

A plane with soft edges created using smoothstep

fragment shader
void main() {
  vec2 newUv = (vUv - vec2(0.5)) * uResolution.zw + vec2(0.5);
  // vec3 color = texture2D(uTexture, newUv).rgb;
  vec3 color = vec3(1.0, 1.0, 1.0);

+ float baseScanLine = 1.0;

+ // Scale `uv.y` by 300 to create 300 lines.
+ vec2 scanLineUv = vUv * vec2(1.0, 300.0);
+ scanLineUv = fract(scanLineUv);

+ // Make the line pattern a gradient
+ baseScanLine = smoothstep(0.0, 0.5, scanLineUv.y);
+ baseScanLine *= smoothstep(1.0, 0.5, scanLineUv.y);
+ color *= baseScanLine;

  gl_FragColor = vec4(color, 1.0);
}

Once you’ve confirmed the shape of the scan lines, bring the texture back.

A plane with the texture restored and scan lines overlaid on top of the image

fragment shader
vec3 color = texture2D(uTexture, newUv).rgb;
// vec3 color = vec3(1.0, 1.0, 1.0);

This would be fine as a finished result, but it looks a bit dark, so we’ll adjust the brightness.
This part is purely a matter of preference, so you can skip it if you like.

The final plane with adjusted brightness, making the scan lines easier to see

baseScanLine *= smoothstep(1.0, 0.5, scanLineUv.y);

+ // Adjust the brightness
+ float minBrightness = 0.5;
+ baseScanLine = mix(minBrightness, 1.0, baseScanLine);

color *= baseScanLine;

There are many ways to approach this, but this time we’ll use the mix function.

For example:

  • mix(red, blue, 0.8) → 20% red, 80% blue
  • mix(red, blue, 0.3) → 70% red, 30% blue

This is how you can blend colors together.

For a more intuitive explanation of the mix function, the diagrams in The Book of Shaders may be helpful.

In this case, since we use baseScanLine = mix(0.5, 1.0, baseScanLine), the values 0.5 and 1.0 are blended according to the ratio given by baseScanLine.
As a result, the output range becomes 0.5–1.0, making the overall image brighter compared to the original 0.0–1.0 range.

With that, the Scan Line effect is complete 🎉

fragment shader
fragment shader
void main() {
  vec2 newUv = (vUv - vec2(0.5)) * uResolution.zw + vec2(0.5);
  vec3 color = texture2D(uTexture, newUv).rgb;
  // vec3 color = vec3(1.0, 1.0, 1.0);

  float baseScanLine = 1.0;

  // scale uv.y by 300 to create 300 lines
  vec2 scanLineUv = vUv * vec2(1.0, 300.0);
  scanLineUv = fract(scanLineUv);

  // make the line pattern a gradient
  baseScanLine = smoothstep(0.0, 0.5, scanLineUv.y);
  baseScanLine *= smoothstep(1.0, 0.5, scanLineUv.y);

  // adjust the brightness
  float minBrightness = 0.5;
  baseScanLine = mix(minBrightness, 1.0, baseScanLine);

  color *= baseScanLine;

  gl_FragColor = vec4(color, 1.0);
}

2. Chromatic Aberration

Next, we’ll create a chromatic aberration effect.

Plane with the Chromatic Aberration effect applied

For clarity, we’ll implement each effect separately in this article.
So first, we’ll start from a state with no effects applied.

Revert the fragment shader to the following state, and make sure the texture is displayed as-is.

A plane displaying the texture

fragment shader
void main() {
  vec2 newUv = (vUv - vec2(0.5)) * uResolution.zw + vec2(0.5);
  vec3 color = texture2D(uTexture, newUv).rgb;
  gl_FragColor = vec4(color, 1.0);
}

Chromatic aberration can be achieved by sampling colors from UV coordinates that are slightly offset from their original positions.
In other words, conceptually, you simply add an offset to newUv, like this:

- vec3 color = texture2D(uTexture, newUv).rgb;
+ vec3 color = texture2D(uTexture, newUv + offset).rgb; // newUvを少しずらす

However, it doesn’t make sense to offset all color channels by the same amount.
If you do that, the entire image will just shift, and you won’t get any color separation.

Chromatic aberration occurs because the R, G, and B channels are sampled from slightly different positions, so you need to use different offsets for each channel.

To keep things simple, we’ll offset only the x-coordinate this time.
That’s why we add 0.0 to newUv.y.

Plane with the Chromatic Aberration effect applied

// Original color 
- vec3 color = texture2D(uTexture, newUv).rgb;

// Per-channel offsets
+ float offsetXR = 0.021;
+ float offsetXG = 0.015;
+ float offsetXB = 0.01;

// Each color channel
// Sample the color from a shifted position only along the x-axis
+ float r = texture2D(uTexture, newUv + vec2(offsetXR, 0.0)).r;
+ float g = texture2D(uTexture, newUv + vec2(offsetXG, 0.0)).g;
+ float b = texture2D(uTexture, newUv + vec2(offsetXB, 0.0)).b;
+ vec3 color = vec3(r, g, b);

The chromatic aberration is a bit too strong, so we’ll tone it down by multiplying it with an arbitrary factor.

A plane with the chromatic aberration strength reduced for a more natural look

+ float offsetXR = 0.021 * 0.6; // Reduce the strength of the effect by multiplying it with an arbitrary factor
+ float offsetXG = 0.015 * 0.6;
+ float offsetXB = 0.01 * 0.6;

With that, the Chromatic Aberration effect is complete!

fragment shader
void main() {
  vec2 newUv = (vUv - vec2(0.5)) * uResolution.zw + vec2(0.5);
  // vec3 color = texture2D(uTexture, newUv).rgb;

  // Per-channel offsets 
  float offsetXR = 0.021 * 0.6;
  float offsetXG = 0.015 * 0.6;
  float offsetXB = 0.01 * 0.6;

  // Each color channel
  // Sample the color from a shifted position only along the x-axis
  float r = texture2D(uTexture, newUv + vec2(offsetXR, 0.0)).r;
  float g = texture2D(uTexture, newUv + vec2(offsetXG, 0.0)).g;
  float b = texture2D(uTexture, newUv + vec2(offsetXB, 0.0)).b;

  vec3 color = vec3(r, g, b);

  gl_FragColor = vec4(color, 1.0);
}

3. Deform Line

Next, we’ll create the Deform Line effect.
Visually, this effect looks like only certain horizontal lines are shifted left and right.

First, let’s take a look at the finished result.

Once again, we’ll start with a clean fragment shader.

fragment shader
void main() {
  vec2 newUv = (vUv - vec2(0.5)) * uResolution.zw + vec2(0.5);
  vec3 color = texture2D(uTexture, newUv).rgb;

  gl_FragColor = vec4(color, 1.0);
}

The Deform Line effect can be implemented by:

  • Dividing the screen into regions along the vertical direction (6 sections in this case)
  • For each region,
  • Sampling colors from UV coordinates that are randomly shifted left or right

This approach is very similar to the mechanism we built in Warm-up 7.

Plane with the Deform Line effect applied

First, to make debugging easier, we’ll comment out the texture and display a white plane.

fragment shader
void main() {
  vec2 newUv = (vUv - vec2(0.5)) * uResolution.zw + vec2(0.5);
  // vec3 color = texture2D(uTexture, newUv).rgb; // textureの色
  vec3 color = vec3(1.0, 1.0, 1.0);

  gl_FragColor = vec4(color, 1.0);
}

Next, just like in Warm-up 7, we’ll use floor and random1d to create regions where the same value is shared within each section.

A plane divided into six vertical sections, each assigned a different grayscale value

+ float random1d(float y) {
+  return fract(sin(x) * 43758.5453);
+ }
 
void main() {
  vec2 newUv = (vUv - vec2(0.5)) * uResolution.zw + vec2(0.5);
  // vec3 color = texture2D(uTexture, newUv).rgb;

+ vec2 uv = vUv;
+ uv.y *= 6.0; // Multiply uv.y by 6
+ uv.y = floor(uv.y); // Create six regions that share the same value
+ float deformLine = random1d(uv.y); // Return a pseudo-random value between 0.0 and 1.0
+ vec3 color = vec3(1.0, 1.0, 1.0) * deformLine;
  
  gl_FragColor = vec4(color, 1.0);
}

With this, each horizontal line now has a different value.

Next, we’ll use these values to sample colors from horizontally shifted UV coordinates.
This idea is the same as what we used for Chromatic Aberration.

Plane with the Deform Line effect applied

void main() {
  vec2 newUv = (vUv - vec2(0.5)) * uResolution.zw + vec2(0.5);
- // vec3 color = texture2D(uTexture, newUv).rgb;

  vec2 uv = vUv;
  uv.y *= 6.0; // Multiply uv.y by 6
  uv.y = floor(uv.y); // Create six regions that share the same value
  float deformLine = random1d(uv.y); // Return a pseudo-random value between 0.0 and 1.0
+ vec3 color = texture2D(uTexture, newUv + vec2(deformLine, 0.0)).rgb; // Sample the color from a shifted position
- vec3 color = vec3(1.0, 1.0, 1.0) * deformLine;

  gl_FragColor = vec4(color, 1.0);
}

The effect is a bit too strong, so we’ll reduce its intensity by multiplying it with an arbitrary facto

A plane with a slightly weakened Deform Line effect applied

  float deformLine = random1d(uv.y); // return a pseudo-random value between 0.0 and 1.0
+ deformLine *= 0.03; // adjust the intensity

Finally, we’ll make the lines continuously flow downward.
This can be achieved simply by adding uTime to uv.y.

- uv.y = floor(uv.y); // create six regions that share the same value
+ uv.y = floor(uv.y + uTime * 1.5); // create six regions that share the same value, and keep them moving

And that’s it—this completes the effect! 🎉
The full code is shown below.

fragment shader
fragment shader
// Pseudo-random number generator (returns 0.0 to 1.0)
// Reference:
// https://thebookofshaders.com/10/
// https://gist.github.com/patriciogonzalezvivo/670c22f3966e662d2f83
float random1d(float x) {
  return fract(sin(x) * 43758.5453);
}

void main() {
  vec2 newUv = (vUv - vec2(0.5)) * uResolution.zw + vec2(0.5);

  vec2 uv = vUv;
  uv.y *= 6.0; // multiply uv.y by 6
  uv.y = floor(uv.y + uTime * 1.5); // create six regions that share the same value
  float deformLine = random1d(uv.y); // return a pseudo-random value between 0.0 and 1.0
  deformLine *= 0.03; // adjust the intensity
  vec3 color = texture2D(uTexture, newUv + vec2(deformLine, 0.0)).rgb; // sample the color from a shifted position

  gl_FragColor = vec4(color, 1.0);
}

4. Combining the Three Glitch Effects

Let’s combine all the glitch effects we’ve created so far and display them together.
For the Deform Line effect, dividing the screen into six sections felt a bit too fine, so I changed it to four sections instead.

fragment shader
fragment shader
precision mediump float;

uniform float uTime;
uniform sampler2D uTexture;
uniform vec4 uResolution;

varying vec2 vUv;

// Pseudo-random number generator (returns 0.0 to 1.0)
// Reference:
// https://thebookofshaders.com/10/
// https://gist.github.com/patriciogonzalezvivo/670c22f3966e662d2f83
float random1d(float x) {
  return fract(sin(x) * 43758.5453);
}

void main() {
  vec2 newUv = (vUv - vec2(0.5)) * uResolution.zw + vec2(0.5);

  /**
   * 1. Deform Line Effect (horizontal distortion)
   */
  vec2 uv = vUv;
  uv.y *= 4.0;
  uv.y = floor(uv.y + uTime * 1.5);
  float deformLine = random1d(uv.y);
  deformLine *= 0.03;

  vec2 distortedUv = newUv + vec2(deformLine, 0.0);

  /**
   * 2. Chromatic Aberration Effect
   */
  float offsetXR = 0.021 * 0.5;
  float offsetXG = 0.015 * 0.5;
  float offsetXB = 0.01 * 0.5;

  float r = texture2D(uTexture, distortedUv + vec2(offsetXR, 0.0)).r;
  float g = texture2D(uTexture, distortedUv + vec2(offsetXG, 0.0)).g;
  float b = texture2D(uTexture, distortedUv + vec2(offsetXB, 0.0)).b;

  vec3 color = vec3(r, g, b);

  /**
   * 3. Chromatic Aberration Effect
   */
  float baseScanLine = 1.0;
  vec2 scanLineUv = vUv * vec2(1.0, 300.0);
  scanLineUv = fract(scanLineUv);

  baseScanLine = smoothstep(0.0, 0.5, scanLineUv.y);
  baseScanLine *= smoothstep(1.0, 0.5, scanLineUv.y);

  float minBrightness = 0.5;
  baseScanLine = mix(minBrightness, 1.0, baseScanLine);

  color *= baseScanLine;

  gl_FragColor = vec4(color, 1.0);
}

Let’s Add More Glitch Effects

In the main explanation, we covered three glitch effects, but I also created a version with many more glitches layered together.
Some of these should be understandable using the concepts we’ve covered so far, so I encourage you to give it a try 💪

From here on, the effects include intense flickering, so I added a button to toggle the animation state.
Be careful with overly aggressive glitch effects 🫠 (a reminder to myself as well).

Five Glitch Effects

The following seven glitch effects are combined:

  • Base Scan Line
  • Chromatic Aberration
  • Deform Line (two variations)
  • Scan Line Down
  • Ghost
fragment shader
fragment shader
precision mediump float;

uniform float uTime;
uniform sampler2D uTexture;
uniform vec4 uResolution;
uniform bool uGlitchEnabled;

varying vec2 vUv;

// https://thebookofshaders.com/10/
// https://gist.github.com/patriciogonzalezvivo/670c22f3966e662d2f83
float random1d(float x) {
  return fract(sin(x) * 43758.5453123);
}

void main() {
  vec2 newUv = (vUv - vec2(0.5)) * uResolution.zw + vec2(0.5);

  /**
   * 1. Deform Line Effects (horizontal distortion)
   */
  float deformLine1 = 0.0;
  float deformLine2 = 0.0;

  if (uGlitchEnabled) {
    {
      // deformLine1 (slow-moving lines)
      vec2 uv = vUv;
      uv.y *= 4.0;
      uv.y = floor(uv.y + uTime * 1.5);

      deformLine1 = random1d(uv.y) - 0.5;
      deformLine1 *= 0.03;
    }

    {
      // deformLine2 (fast-moving lines)
      vec2 uv = vUv;
      uv.y = step(0.01, fract(uv.y + uTime * 2.5));

      deformLine2 = random1d(uv.y) - 0.5;
      deformLine2 *= 0.03;
    }
  }

  vec2 distortedUv = newUv + vec2(deformLine1 + deformLine2, 0.0);

  /**
   * 2. Chromatic Aberration Effect
   */
  float offsetXR = 0.021 * 0.5;
  float offsetXG = 0.015 * 0.5;
  float offsetXB = 0.01 * 0.5;

  float r = texture2D(uTexture, distortedUv + vec2(offsetXR, 0.0)).r;
  float g = texture2D(uTexture, distortedUv + vec2(offsetXG, 0.0)).g;
  float b = texture2D(uTexture, distortedUv + vec2(offsetXB, 0.0)).b;

  vec3 color = vec3(r, g, b);

  /**
   * 3. Ghost Effect
   */
  if (uGlitchEnabled) {
    float flickerTime = floor(uTime * 50.0);
    float flicker = random1d(flickerTime);
    float flickerX = random1d(flickerTime + 200.0);
    float flickerY = random1d(flickerTime + 100.0);

    float offsetY = (flickerY - 0.5);
    offsetY *= 0.1;
    float offsetX = (flickerX - 0.5);
    offsetX *= 0.1;
    vec3 ghostColor = texture2D(uTexture, distortedUv + vec2(offsetX, offsetY)).rgb;

    float mixFactor = flicker > 0.8 ? 0.2 : 0.0;
    color = mix(color, ghostColor, mixFactor);
  }

  /**
   * 4. Base Scan Line Effect
   */
  float baseScanLine = 1.0;
  {
    vec2 scanLineUv = vUv * vec2(1.0, 300.0);
    scanLineUv = fract(scanLineUv);

    baseScanLine = smoothstep(0.0, 0.5, scanLineUv.y);
    baseScanLine *= smoothstep(1.0, 0.5, scanLineUv.y);

    float minBrightness = 0.5;
    baseScanLine = mix(minBrightness, 1.0, baseScanLine);
  }

  /**
   * 5. Scan Line Down Effect (moving scan line with flicker)
   */
  float scanLineDown = 1.0;
  if (uGlitchEnabled) {
    vec2 scanLineDownUv = vUv * vec2(1.0, 8.0);
    scanLineDownUv.y = floor(scanLineDownUv.y + 5.0 * uTime);

    scanLineDown = random1d(scanLineDownUv.y);

    float isFlickerOn = step(0.0, sin(uTime * 1000.0));
    float minBrightness = isFlickerOn == 1.0 ? 0.8 : 0.95;
    scanLineDown = mix(minBrightness, 1.0, scanLineDown);
  }

  color = color * baseScanLine * scanLineDown;

  gl_FragColor = vec4(color, 1.0);
}

Nine Glitch Effects

The following nine glitch effects are layered together:

  • Base Scan Line
  • Chromatic Aberration
  • Deform Line (two variations)
  • Scan Line Down
  • Ghost
  • Wave
  • Wave Form Warp
  • Block Noise
  • Grain Noise
fragment shader
fragment shader
precision mediump float;

uniform float uTime;
uniform sampler2D uTexture;
uniform vec4 uResolution;
uniform bool uGlitchEnabled;

varying vec2 vUv;

// https://thebookofshaders.com/10/
// https://gist.github.com/patriciogonzalezvivo/670c22f3966e662d2f83
float random1d(float x) {
  return fract(sin(x) * 43758.5453123);
}

// Author @patriciogv - 2015
// http://patriciogonzalezvivo.com
// random2d function (returns 0.0 to 1.0)
float random2d(vec2 st) {
  return fract(sin(dot(st.xy, vec2(12.9898, 78.233))) * 43758.5453123);
}

// Simplex 2D noise
vec3 permute(vec3 x) {
  return mod(((x * 34.0) + 1.0) * x, 289.0);
}

float snoise(vec2 v) {
  const vec4 C = vec4(0.211324865405187, 0.366025403784439, -0.577350269189626, 0.024390243902439);
  vec2 i = floor(v + dot(v, C.yy));
  vec2 x0 = v - i + dot(i, C.xx);
  vec2 i1;
  i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
  vec4 x12 = x0.xyxy + C.xxzz;
  x12.xy -= i1;
  i = mod(i, 289.0);
  vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0));
  vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), 0.0);
  m = m * m;
  m = m * m;
  vec3 x = 2.0 * fract(p * C.www) - 1.0;
  vec3 h = abs(x) - 0.5;
  vec3 ox = floor(x + 0.5);
  vec3 a0 = x - ox;
  m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h);
  vec3 g;
  g.x = a0.x * x0.x + h.x * x0.y;
  g.yz = a0.yz * x12.xz + h.yz * x12.yw;
  return 130.0 * dot(m, g);
}

void main() {
  vec2 newUv = (vUv - vec2(0.5)) * uResolution.zw + vec2(0.5);

  /**
   * 1. Wave Form Warp Effect
   */
  float waveFormWarp = 0.0;
  if (uGlitchEnabled) {
    float glitchTime = uTime * 20.0 + random1d(vUv.y);
    float glitch = sin(glitchTime) + sin(glitchTime * 3.24) + sin(glitchTime * 9.21);
    glitch /= 3.0;
    glitch = smoothstep(-0.5, 0.6, glitch);
    glitch *= 0.005;
    waveFormWarp = (random1d(vUv.y + uTime) - 0.5) * glitch;
  }

  /**
   * 2. Wave Effect
   */
  float wave = 0.0;
  if (uGlitchEnabled) {
    float flicker = sin(uTime * 20.0);
    float noise = snoise(vec2(vUv.y, uTime * 90.0));
    wave = flicker > 0.5 ? sin(noise * 20.0) * 0.03 : 0.0;
  }

  /**
   * 3. Block Noise Effect
   */
  float blockNoise = 0.0;
  if (uGlitchEnabled) {
    float noise1 = snoise(floor(vUv * vec2(10.0, 12.0)) + uTime);
    float noise2 = snoise(floor(vUv * vec2(6.0, 4.0)) + uTime);
    float noise3 = snoise(floor(vUv * vec2(3.0, 8.0)) + uTime);

    blockNoise = noise1 * noise2 * noise3;
    blockNoise = step(0.3, blockNoise);

    float flickerTime = floor(uTime * 50.0);
    float flicker = random1d(flickerTime);
    blockNoise = flicker > 0.5 ? blockNoise : 0.0;
    blockNoise *= 0.5;
  }

  /**
   * 4. Deform Line Effects (horizontal distortion)
   */
  float deformLine1 = 0.0;
  float deformLine2 = 0.0;

  if (uGlitchEnabled) {
    {
      // deformLine1 (slow-moving lines)
      vec2 uv = vUv;
      uv.y *= 4.0;
      uv.y = floor(uv.y + uTime * 1.5);

      deformLine1 = random1d(uv.y) - 0.5;
      deformLine1 *= 0.03;
    }

    {
      // deformLine2 (fast-moving lines)
      vec2 uv = vUv;
      uv.y = step(0.01, fract(uv.y + uTime * 2.5));

      deformLine2 = random1d(uv.y) - 0.5;
      deformLine2 *= 0.03;
    }
  }

  vec2 distortedUv = newUv + vec2(blockNoise + waveFormWarp + deformLine1 + deformLine2 + wave, 0.0);

  /**
   * 5. Chromatic Aberration Effect
   */
  float offsetXR = 0.021 * 0.5;
  float offsetXG = 0.015 * 0.5;
  float offsetXB = 0.01 * 0.5;

  float r = texture2D(uTexture, distortedUv + vec2(offsetXR, 0.0)).r;
  float g = texture2D(uTexture, distortedUv + vec2(offsetXG, 0.0)).g;
  float b = texture2D(uTexture, distortedUv + vec2(offsetXB, 0.0)).b;

  vec3 color = vec3(r, g, b);

  /**
   * 6. Ghost Effect
   */
  if (uGlitchEnabled) {
    float flickerTime = floor(uTime * 50.0);
    float flicker = random1d(flickerTime);
    float flickerX = random1d(flickerTime + 200.0);
    float flickerY = random1d(flickerTime + 100.0);

    float offsetY = (flickerY - 0.5);
    offsetY *= 0.1;
    float offsetX = (flickerX - 0.5);
    offsetX *= 0.1;
    vec3 ghostColor = texture2D(uTexture, distortedUv + vec2(offsetX, offsetY)).rgb;

    float mixFactor = flicker > 0.8 ? 0.2 : 0.0;
    color = mix(color, ghostColor, mixFactor);
  }

  /**
   * 7. Base Scan Line Effect
   */
  float baseScanLine = 1.0;
  {
    vec2 scanLineUv = vUv * vec2(1.0, 300.0);
    scanLineUv = fract(scanLineUv);

    baseScanLine = smoothstep(0.0, 0.5, scanLineUv.y);
    baseScanLine *= smoothstep(1.0, 0.5, scanLineUv.y);

    float minBrightness = 0.5;
    baseScanLine = mix(minBrightness, 1.0, baseScanLine);
  }

  /**
   * 8. Scan Line Down Effect (moving scan line with flicker)
   */
  float scanLineDown = 1.0;
  if (uGlitchEnabled) {
    vec2 scanLineDownUv = vUv * vec2(1.0, 8.0);
    scanLineDownUv.y = floor(scanLineDownUv.y + 5.0 * uTime);

    scanLineDown = random1d(scanLineDownUv.y);

    float isFlickerOn = step(0.0, sin(uTime * 1000.0));
    float minBrightness = isFlickerOn == 1.0 ? 0.8 : 0.95;
    scanLineDown = mix(minBrightness, 1.0, scanLineDown);
  }

  /**
   * 9. Grain Noise Effect
   */
  float grainNoise = 1.0;
  if (uGlitchEnabled) {
    grainNoise = random2d(vUv + uTime);
    float brightness = 0.8;
    grainNoise = mix(brightness, 1.0, grainNoise);
  }

  color = color * baseScanLine * scanLineDown * grainNoise;

  gl_FragColor = vec4(color, 1.0);
}

In Closing

Thank you very much for reading this extremely long article all the way to the end.
Explaining shaders in words turned out to be far more difficult than I had imagined, and it made me once again appreciate and respect those who have shared detailed explanatory articles before me.

Tomorrow’s entry in the Money Forward Engineers Advent Calendar 2025 will be written by Takudai Kawasaki—look forward to it! 🎅🦌

References: Helpful Resources for Learning

Discussion