Skip to example

Three.js shader lens

An example of creating a pointer-driven refraction lens by animating Three.js ShaderMaterial uniforms and Vector2 axes with threeEffect in Motion.

JavaScript

Source code

<div class="stage">
  <canvas aria-label="A refractive lens moving over a photograph"></canvas>
</div>

<script type="module">
  import { animate, frame } from "motion"
  import { threeEffect } from "motion/three"
  import * as THREE from "three"

  animate.addEffect(threeEffect)

  const PHOTO_URL = "/photos/japan/4.jpg"
  const stage = document.querySelector(".stage")
  const canvas = document.querySelector("canvas")
  const spring = { type: "spring", stiffness: 120, damping: 20 }

  function runLensMotion(material) {
    /**
     * Animate the ShaderMaterial. threeEffect resolves lensX and lensY to
     * the axes of its lens Vector2 uniform.
     */
    canvas.addEventListener("pointermove", (event) => {
      const rect = canvas.getBoundingClientRect()

      animate(
        material,
        {
          lensX: THREE.MathUtils.clamp(
            (event.clientX - rect.left) / rect.width,
            0,
            1
          ),
          lensY: THREE.MathUtils.clamp(
            1 - (event.clientY - rect.top) / rect.height,
            0,
            1
          ),
        },
        spring
      )
    })
  }

  const vertexShader = `
    varying vec2 vUv;

    void main() {
      vUv = uv;
      gl_Position = vec4(position, 1.0);
    }
  `

  const fragmentShader = `
    varying vec2 vUv;
    uniform sampler2D photo;
    uniform vec2 lens;
    uniform float aspect;
    uniform float imageAspect;

    vec3 sampleScene(vec2 canvasUv) {
      vec2 imageSize = vec2(min(aspect * 0.72, 0.96), 0.0);
      imageSize.y = imageSize.x / imageAspect;

      if (imageSize.y > 0.72) {
        imageSize = vec2(0.72 * imageAspect, 0.72);
      }

      vec2 imageSizeUv = imageSize / vec2(aspect, 1.0);
      vec2 imageCenter = vec2(0.5, 0.46);
      vec2 imageMin = imageCenter - imageSizeUv * 0.5;
      vec2 uv = (canvasUv - imageMin) / imageSizeUv;
      float onPhoto =
        step(0.0, uv.x) *
        step(uv.x, 1.0) *
        step(0.0, uv.y) *
        step(uv.y, 1.0);
      vec3 texel = texture2D(
        photo,
        clamp(uv, vec2(0.0), vec2(1.0))
      ).rgb;

      return mix(vec3(1.0), texel, onPhoto);
    }

    vec2 refractedUv(vec2 canvasUv, vec2 local, float ior) {
      float r2 = dot(local, local);
      float angle = atan(local.y, local.x);
      float distortion = sin(angle * 6.0 + r2 * 8.0) * 0.65;
      vec2 distorted = local * (1.0 + distortion * r2 * 0.16);
      vec3 normal = normalize(vec3(
        distorted,
        sqrt(max(0.0, 1.0 - r2))
      ));
      vec3 ray = refract(vec3(0.0, 0.0, -1.0), normal, 1.0 / ior);
      float edgeMagnification = mix(
        0.75,
        2.4,
        smoothstep(0.2, 1.0, r2)
      );

      return canvasUv +
        ray.xy * 0.075 * edgeMagnification /
        vec2(aspect, 1.0);
    }

    void main() {
      vec2 scale = vec2(aspect, 1.0);
      vec2 local = (vUv - lens) * scale / 0.14;
      float r2 = dot(local, local);
      float radius = sqrt(r2);
      float mask = 1.0 - smoothstep(0.985, 1.0, radius);
      vec3 base = sampleScene(vUv);

      float chromaticAberration = 0.65;
      float spread = chromaticAberration * 0.08;
      vec3 red = sampleScene(refractedUv(vUv, local, 1.14 - spread));
      vec3 green = sampleScene(refractedUv(vUv, local, 1.14));
      vec3 blue = sampleScene(refractedUv(vUv, local, 1.14 + spread));
      vec3 glass = vec3(red.r, green.g, blue.b);

      float normalZ = sqrt(max(0.0, 1.0 - r2));
      float fresnel = pow(1.0 - normalZ, 3.0);
      float highlight = pow(max(
        dot(
          normalize(vec3(local, normalZ)),
          normalize(vec3(-0.45, -0.65, 1.0))
        ),
        0.0
      ), 24.0);
      glass += vec3(fresnel * 0.1 + highlight * 0.12);

      gl_FragColor = vec4(mix(base, glass, mask), 1.0);

      #include <colorspace_fragment>
    }
  `

  async function loadPhoto(url) {
    const texture = await new THREE.TextureLoader().loadAsync(url)
    const width = texture.image.naturalWidth || texture.image.width
    const height = texture.image.naturalHeight || texture.image.height

    if (!width || !height) {
      texture.dispose()
      throw new Error("The photograph has invalid dimensions")
    }

    texture.colorSpace = THREE.SRGBColorSpace
    texture.wrapS = THREE.ClampToEdgeWrapping
    texture.wrapT = THREE.ClampToEdgeWrapping
    texture.magFilter = THREE.LinearFilter
    texture.needsUpdate = true

    return {
      texture,
      aspect: width / height,
    }
  }

  async function start() {
    const renderer = new THREE.WebGLRenderer({
      canvas,
      antialias: true,
    })
    renderer.outputColorSpace = THREE.SRGBColorSpace
    renderer.setClearColor(0xffffff, 1)

    let uniforms

    function resize() {
      const width = Math.max(1, canvas.clientWidth)
      const height = Math.max(1, canvas.clientHeight)

      renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2))
      renderer.setSize(width, height, false)

      if (uniforms) {
        uniforms.aspect.value = width / height
      }
    }

    const resizeObserver = new ResizeObserver(resize)
    resizeObserver.observe(stage)
    window.addEventListener("resize", resize)
    resize()

    const loadedPhoto = await loadPhoto(PHOTO_URL)
    uniforms = {
      photo: { value: loadedPhoto.texture },
      lens: { value: new THREE.Vector2(0.5, 0.46) },
      aspect: { value: 1 },
      imageAspect: { value: loadedPhoto.aspect },
    }
    resize()

    const material = new THREE.ShaderMaterial({
      uniforms,
      vertexShader,
      fragmentShader,
      depthTest: false,
      depthWrite: false,
    })
    const scene = new THREE.Scene()
    const camera = new THREE.Camera()
    const mesh = new THREE.Mesh(
      new THREE.PlaneGeometry(2, 2),
      material
    )
    scene.add(mesh)
    runLensMotion(material)

    let isReady = false

    frame.render(() => {
      renderer.render(scene, camera)

      if (!isReady) {
        isReady = true
        canvas.classList.add("is-ready")
      }
    }, true)

  }

  start().catch((error) => {
    console.error(error)
  })
</script>

<style>
  #sandbox {
    align-items: stretch;
    background: #fff;
  }

  #example-container {
    display: flex;
    flex: 1;
    width: 100%;
    min-height: 0;
    background: #fff;
  }

  .stage {
    position: relative;
    flex: 1;
    min-height: 0;
    overflow: hidden;
    background: #fff;
  }

  .stage canvas {
    position: absolute;
    inset: 0;
    display: block;
    width: 100%;
    height: 100%;
    opacity: 0;
    cursor: none;
    touch-action: none;
  }

  .stage canvas.is-ready {
    opacity: 1;
  }
</style>