Aphiwad Chhoeun

RGB Shift Hover Effect with WebGL

Making hover effect with WebGL using Three.js

A short walk-through of how to achieve hover effect with WebGL. I will use Three.js.

HTML markup:

Let’s start simple, create HTML markups like so:

<div class="container">
  <div class="gallery__wrapper">
    <div class="item" data-author="Mak">
      <img
        src="https://images.unsplash.com/photo-1590671646317-29ee9058e244?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=600&q=80"
      />
    </div>
  </div>
</div>

With some styling, it should be looking something like this:

hover-rgb-1

Adding a “Hello World” Three.js canvas:

(this is copied from getting started Three.js’s docs)

var scene = new THREE.Scene()
var camera = new THREE.PerspectiveCamera(
  75,
  window.innerWidth / window.innerHeight,
  0.1,
  1000
)

var renderer = new THREE.WebGLRenderer()
renderer.setSize(window.innerWidth, window.innerHeight)
document.body.appendChild(renderer.domElement)

var geometry = new THREE.BoxGeometry()
var material = new THREE.MeshBasicMaterial({ color: 0x00ff00 })
var cube = new THREE.Mesh(geometry, material)
scene.add(cube)

camera.position.z = 5

var animate = function () {
  requestAnimationFrame(animate)

  cube.rotation.x += 0.01
  cube.rotation.y += 0.01

  renderer.render(scene, camera)
}

animate()

Loading texture from

We need to load the texture of the image we use in <img> into a Plane on canvas.

// loading texture
let textureLoader = new THREE.TextureLoader()
let texture = textureLoader.load(image.getAttribute('src'))

// creating plane
let geometry = new THREE.PlaneBufferGeometry(6, 4, 32, 32)
let material = new THREE.MeshBasicMaterial({ map: this.textures[0] })
let plane = new THREE.Mesh(geometry, material)

// adding plane into scene
scene.add(plane)

Switch to EffectComposer for rendering

To achieve the hover effect, we need to apply post-processing effect on our renderer, and amplify the effect based on mouse position. But first we need to switch the renderer to EffectComposer instead because all post-processing effects need to be render through Effect Composer:

import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer'
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass'
import { ShaderPass } from 'three/examples/jsm/postprocessing/ShaderPass'

// creating effect composer
let composer = new EffectComposer(renderer)
composer.addPass(new RenderPass(scene, camera))

Remember to replace render(), we no longer render from our original renderer, instead we use the newly created Effect Composer instance:

// instead of this:
renderer.render(this.scene, this.camera)

// we render from Effect Composer instead:
composer.render()

Apply custom effect

Now into the main custom effect. In order to apply post-processing effect, we would need a custom ShaderPass and put it into another layer of pass through EffectComposer. Our custom effect displace the Red Channel & Blue Channel then merge with the original texture:

let uniforms = {
  uAmount: {
    value: 0.008, // strength of effect
  },
  uMouse: {
    value: new THREE.Vector2(0.5, 0.5), // position of mouse
  },
}
let vertexShader = `
    varying vec2 vUv;

    void main() {
      vUv = uv;
      gl_Position = projectionMatrix * modelViewMatrix * vec4( position , 1.0 );
    }
  `
let fragmentShader = `
    uniform sampler2D tDiffuse;
    uniform float uAmount;
    uniform vec2 uMouse;

    varying vec2 vUv;

    float circle(vec2 uv, vec2 disc_center, float disc_radius, float border_size) {
      float dist = distance(uv, disc_center);
      return smoothstep(disc_radius+border_size, disc_radius-border_size, dist);
    }

    void main() {
      float c = uAmount * circle(vUv, uMouse, 0., 0.2); // displacing vertices

      vec4 cr = texture2D(tDiffuse, (vUv + c)); // extract Red channel 
      vec4 cga = texture2D(tDiffuse, vUv);
      vec4 cb = texture2D(tDiffuse, (vUv - c)); // extract Blue channel

      gl_FragColor = vec4(cga.r, cr.g, cb.b, cga.a); // merge together
    }
  `
let effectPass = new ShaderPass({
  uniforms: this.uniforms,
  vertexShader: vertexShader,
  fragmentShader: fragmentShader,
})
effectPass.renderToScreen = true

// add to EffectComposer
composer.addPass(this.effectPass)

Now we start tracking mouse position and pass it into ShaderPass: uniforms

document.addEventListener(\"mousemove\", (e) => {
  mousePosition.x = e.clientX;
  mousePosition.y = e.clientY;
});

Then in animation loop, we calculate the speed of the mouse movement and update ShaderPass uniforms.

// calculating speeds
mouseSpeed.x = (this.mousePosition.x - this.effectPosition.x) * 0.08
mouseSpeed.y = (this.mousePosition.y - this.effectPosition.y) * 0.08

// passing updated effect position into shader
effectPass.uniforms.uMouse.value.x = effectPosition.x / window.innerWidth
effectPass.uniforms.uMouse.value.y = effectPosition.y / window.innerHeight

// update effect position based on speed
effectPosition.x += mouseSpeed.x
effectPosition.y += mouseSpeed.y

We multiply 0.08 with mouse position to get a smooth delay effect of mouse movement.

That’s it! you can check out a live demo, and source.