loading...
Cover image for RGB Splitting Effect with HTML5 Canvas and JavaScript

RGB Splitting Effect with HTML5 Canvas and JavaScript

hangindev profile image Jason Leung πŸ§—β€β™‚οΈπŸ‘¨β€πŸ’» Updated on ・7 min read

Recently, I followed Honeypot on Twitter. In case you didn't know, Honeypot is a developer-focused job platform that also produces awesome documentaries exploring tech culture. On their page, they like to use this RGB splitting technique in their cover images to create a glitch effect. Neat. So I figured I'd write a post explaining how it can be done with HTML5 canvas and JavaScript to those who are new to image processing on the web.

Honeypot Screencapture


Honeypot likes to use this RGB splitting effect on their page.

Walk-through πŸšΆβ€β™€οΈπŸšΆβ€β™‚οΈ

Live demo

Open this CodeSandbox if you want to follow along. Let's walk through the files. First, I scaffolded the structure inside body of index.html so that we can focus on writing JavaScript. I also added a stylesheet in the head which I will not go into but feel free to have a look.

<body>
  <!-- Before / After -->
  <div class="container">
    <div>
      <p>Original Image:</p>
      <img id="Source" src="/demo.jpg" crossorigin="anonymous" />
    </div>
    <div>
      <p>Canvas:</p>
      <canvas id="Canvas"></canvas>
    </div>
  </div>
  <!-- Control Sliders -->
  <div class="control">
    <div class="red">
      <label>R:</label>
      <input id="rOffset" type="range" min="-100" max="100" step="5" />
    </div>
    <div class="green">
      <label>G:</label>
      <input id="gOffset" type="range" min="-100" max="100" step="5" />
    </div>
    <div class="blue">
      <label>B:</label>
      <input id="bOffset" type="range" min="-100" max="100" step="5" />
    </div>
  </div>
  <!-- Reference the external script -->
  <script src="app.js"></script>
</body>

The crossorigin="anonymous" attribute on the img tag tells the browser to fetch the image with CORS(Cross-Origin Resource Sharing) header. I will write more about CORS in the future. For now, just keep in mind if you fail to do some operations on a canvas, it may be related to CORS.

Then there are two js files. app.js contains the minimal code to get you started. If at every time you want to look at the finished code, you can check app-finish.js.

// Find all elements that will be used and assign them to variables
const image = document.getElementById("Source");
const canvas = document.getElementById("Canvas");
const rOffsetInput = document.getElementById("rOffset");
const gOffsetInput = document.getElementById("gOffset");
const bOffsetInput = document.getElementById("bOffset");
// If the image is completely loaded before this script executes, call init().
if (image.complete) init();
// In case it is not loaded yet, we listen to its "load" event and call init() when it fires.
image.addEventListener("load", init);
function init() {
  // Where the Magic Happens
}

Display the Image on Canvas

If you have used canvas before, feel free to fast forward to the real action.

For any image processing tasks you'd like to perform, you will most likely need to use the canvas element. canvas is a powerful playground for you to play with image data, apply filters and overlays effects. And you are not limited to static images but you can even manipulate video data with canvas. Here let's first try to draw the image from the img element to the canvas.

To draw anything on the canvas, you will need to get a drawing context using getContext method. Then, we will set the canvas drawing dimensions (as opposed to the display dimensions set by CSS) to the intrinsic width and height of the image. Finally, we will use the drawImage method to draw the image onto the canvas. (Save the file using ctrl+s/cmd+s after changes to see the update.)

function init() {
  // Get a two-dimensional rendering context
  const ctx = canvas.getContext("2d");
  const width = image.naturalWidth;
  const height = image.naturalHeight;
  canvas.width = width;
  canvas.height = height;
  ctx.drawImage(image, 0, 0, width, height);
}

Syntax: drawImage(image, dx, dy, dWidth, dHeight) where image is the image elemnet to show, dx, dy are the x and y coordinate in the canvas at which to place the top-left corner of the image, and dWidth, dHeight are the width and height to draw the image in the canvas.

Peek into the ImageData

Now, let's use getImageData to get the image data out and see what is in it using console.log. Do not use the console CodeSandbox provides since the ImageData object is a fairly large object. Instead, open the browser in a new window and use the native console of the browser.

function init() {
  const ctx = canvas.getContext("2d");
  const width = image.naturalWidth;
  const height = image.naturalHeight;
  canvas.width = width;
  canvas.height = height;
  ctx.drawImage(image, 0, 0, width, height);
  // πŸ‘‡
  const imageData = ctx.getImageData(0, 0, width, height);
  console.log(imageData);
}

Syntax: ctx.getImageData(sx, sy, sw, sh) where sx, sy are the x and y coordinate of the top-left corner of the rectangle from which the data will be extracted, and sw, sh are the width and height of the rectangle from which the data will be extracted.

The imageData object has three properties: width and height are the actual dimensions of the image data we extracted, which in this case is also the dimensions of our image and canvas. The data property is an Uint8ClampedArray which is an array-like object used to store values between 0-255(inclusive). Values smaller than 0 or greater than 255 will be clamped to 0 and 255.

So what is this array representing? If you have used rgb color in CSS, you may have a sense that it is something related and you are right. This Uint8ClampedArray is a one-dimensional array representing the color in the RGBA(red, green, blue, alpha) order of every pixel in the image. In other words, every four values in this array represent a pixel in the image.

ImageData {data: Uint8ClampedArray[14, 34, 58, 255, 38, 60, 81, 255, 46, 75, 93, 255…], width: 640, height: 427}

Time to Tear Them Apart

Now that we've learned about ImageData. It's time for the fun part. (finally!) The idea behind the RGB splitting is to shift each channel of color(red, green, or blue) to different directions. To implement it, we will create a helper function called rgbSplit. (create it above or below the init function)

function rgbSplit(imageData, options) {
  // destructure the offset values from options, default to 0
  const { rOffset = 0, gOffset = 0, bOffset = 0 } = options; 
  // clone the pixel array from original imageData
  const originalArray = imageData.data;
  const newArray = new Uint8ClampedArray(originalArray);
  // loop through every pixel and assign values to the offseted position
  for (let i = 0; i < originalArray.length; i += 4) {
    newArray[i + 0 + rOffset * 4] = originalArray[i + 0]; // πŸ”΄
    newArray[i + 1 + gOffset * 4] = originalArray[i + 1]; // 🟒
    newArray[i + 2 + bOffset * 4] = originalArray[i + 2]; // πŸ”΅
  }
  // return a new ImageData object
  return new ImageData(newPixels, imageData.width, imageData.height);
}

rgbSplit takes in ImageData and an options object as arguments. The options object should have three properties: rOffset, gOffset, bOffset which represent the pixel offset of each color channel.

Next, instead of mutating the data values in ImageData, let's make a copy of it by calling the Uint8ClampedArray constructor and passing it the original data array. Then, we will loop through every pixel and manipulate the color in each of them. Remember four values in that array represent one pixel? That's why we are setting the increment expression to be i += 4.

In each iteration, we take each color intensity from the original array and place it to a new position based on the offset value provided. Again, we are multiplying the offset value by 4 since four values represent one pixel.

πŸ”΄πŸŸ’πŸ”΅βšͺ πŸ”΄πŸŸ’πŸ”΅βšͺ πŸ”΄πŸŸ’πŸ”΅βšͺ πŸ”΄πŸŸ’πŸ”΅βšͺ

To use the rgbSplit funciton, we go back into the init function. We call the rgbSplit funciton with the imageData we got from the canvas context and also some random offset values. We will then paint the new image data onto the canvas using the putImageData method.

function init() {
  const ctx = canvas.getContext("2d");
  const width = image.naturalWidth;
  const height = image.naturalHeight;
  canvas.width = width;
  canvas.height = height;
  ctx.drawImage(image, 0, 0, width, height);
  const imageData = ctx.getImageData(0, 0, width, height);
  // πŸ‘‡
  const updatedImageData = rgbSplit(imageData, {
    rOffset: 20,
    gOffset: -10,
    bOffset: 10
  });
  ctx.putImageData(updatedImageData, 0, 0);
}

Syntax: ctx.putImageData(imageData, dx, dy) where imageData is the ImageData object containing the array of pixel values and dx, dy are the x and y coordinate at which to place the image data in the destination canvas.

And voila.

Images with and without RGB splitting effect applied

Captain America Lumber

Bonus: Implement the Sliders

Lastly, with the help of the rgbSplit function, the implementation of the slider control will be straightforward. We just have to listen to the slider "change" event and call the rgbSplit function with the values of the sliders.

function init() {
  const ctx = canvas.getContext("2d");
  const width = image.naturalWidth;
  const height = image.naturalHeight;
  canvas.width = width;
  canvas.height = height;
  ctx.drawImage(image, 0, 0, width, height);
  const imageData = ctx.getImageData(0, 0, width, height);
  // const updatedImageData = rgbSplit(imageData, {
  //   rOffset: 30,
  //   gOffset: -10,
  //   bOffset: 10
  // });
  // ctx.putImageData(updatedImageData, 0, 0);
  rOffsetInput.addEventListener("change", updateCanvas);
  gOffsetInput.addEventListener("change", updateCanvas);
  bOffsetInput.addEventListener("change", updateCanvas);

  // Put this function inside init since we have to access imageData
  function updateCanvas() {
    const updatedImageData = rgbSplit(imageData, {
      // turn string value into integer
      rOffset: Number(rOffsetInput.value), 
      gOffset: Number(gOffsetInput.value),
      bOffset: Number(bOffsetInput.value)
    });
    ctx.putImageData(updatedImageData, 0, 0);
  }
}

Wrap up

Are you still here? What's meant to be a simple article has turned into one of my longest posts. But I hope you have learned something and get to play with the canvas element. Please let me know your feedback. Do you think if the post is too lengthy? Or did I not explain some concepts well enough? Anyway, thanks a lot for reading. Until next time! πŸ‘‹

I am planning to write about an Instagram Effect Clone with actual functionality and I hope to make it beginner-friendly using just plain HTML, CSS, and JavaScript. If you would like to get the latest updates, please follow me on DEV and Twitter. πŸ˜‰

Discussion

markdown guide