← 3D & depth

2.5D Photo Parallax

A flat picture comes alive in 3D. A greyscale depth map says how far away each part of it is, and as the view drifts the tree in front slides across the hills behind it, while the distant mountains barely move. Move the pointer over it, or drag a finger, to look around.

A valley at dusk, with a tree in front of layered hills

How to use it

  1. Paste the snippet just before </head>.
  2. Make a depth map for your photo: a greyscale image of the same shape, white where things are near and black where they’re far. Depth-estimation tools make one in seconds; a soft blur helps.
  3. Put the photo in a box with data-depth-photo-parallax, and point data-map at the depth map.

Options

Add any of these attributes to the same element.

data-mapThe depth map’s URL. Required.
data-amountHow far the view moves, as a share of the width. Default 0.03.
data-focusThe depth that stays still, from 0 (far) to 1 (near). Default 0.45.
data-speedSpeed of the drift when no one’s pointing. Default 1.
data-pointer="false"Ignore the pointer and just drift.

Drawn with a small WebGL shader, paused when off screen. The images must come from your own site or allow CORS. Without WebGL, or with reduced motion, the plain photo shows.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // 2.5D Photo Parallax: a flat photo moves in 3D. A greyscale depth map
    // (white = near, black = far) tells each pixel how far away it is, and a
    // small WebGL shader shifts near pixels more than far ones as the view
    // drifts, so the picture seems to have real depth. It follows the mouse
    // pointer, or a finger dragged across it, and drifts on its own otherwise.
    // Usage:
    //   <div data-depth-photo-parallax data-map="photo-depth.png" style="aspect-ratio: 16 / 10">
    //     <img src="photo.jpg" alt="…">
    //   </div>
    // Optional: data-amount (how far it moves, as a share of the width,
    // default 0.03), data-focus (the depth that stays still, 0 far to 1 near,
    // default 0.45), data-speed (speed of the drift, default 1),
    // data-pointer="false" (ignore the pointer and just drift).
    // Without WebGL, or until the images load, the plain photo shows.
    var DEFAULTS = { amount: 0.03, focus: 0.45, speed: 1 };

    var css =
      '[data-depth-photo-parallax]{position:relative;overflow:hidden;touch-action:pan-y}' +
      '[data-depth-photo-parallax]>img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block}' +
      '.dpp-canvas{position:absolute;inset:0;width:100%;height:100%;display:block;opacity:0;transition:opacity .4s}' +
      '.dpp-on .dpp-canvas{opacity:1}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    var VERT = 'attribute vec2 p;varying vec2 v;void main(){v=p*.5+.5;v.y=1.-v.y;gl_Position=vec4(p,0.,1.);}';
    // Each pixel looks back along the view, from the nearest depth to the
    // farthest, for the first surface it hits, then narrows that down. So near
    // things slide over far ones and cover them, instead of smearing.
    var FRAG = [
      'precision highp float;',
      'uniform sampler2D img;uniform sampler2D dep;',
      'uniform vec2 off;uniform vec2 sc;uniform float focus;',
      'varying vec2 v;',
      'void main(){',
      '  vec2 uv=(v-.5)*sc+.5;',
      '  float h=1.,prev=1.;',
      '  for(int i=0;i<32;i++){h=1.-float(i)/31.;if(texture2D(dep,uv-off*(h-focus)).r>=h)break;prev=h;}',
      '  float a=h,b=prev;',
      '  for(int j=0;j<5;j++){float m=(a+b)*.5;if(texture2D(dep,uv-off*(m-focus)).r>=m)a=m;else b=m;}',
      '  gl_FragColor=texture2D(img,clamp(uv-off*(a-focus),0.,1.));',
      '}'
    ].join('\n');

    function load(src) {
      return new Promise(function (ok, fail) {
        var im = new Image();
        im.crossOrigin = 'anonymous';
        im.onload = function () { ok(im); };
        im.onerror = fail;
        im.src = src;
      });
    }

    // Draw through a 2D canvas so SVGs and odd sizes upload reliably.
    function raster(im, max) {
      var w = im.naturalWidth || im.width || 1024, h = im.naturalHeight || im.height || 640;
      var k = Math.min(1, max / Math.max(w, h));
      var c = document.createElement('canvas');
      c.width = Math.round(w * k);
      c.height = Math.round(h * k);
      c.getContext('2d').drawImage(im, 0, 0, c.width, c.height);
      return c;
    }

    function texture(gl, unit, source) {
      var t = gl.createTexture();
      gl.activeTexture(gl.TEXTURE0 + unit);
      gl.bindTexture(gl.TEXTURE_2D, t);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);
    }

    function program(gl) {
      function sh(type, src) {
        var s = gl.createShader(type);
        gl.shaderSource(s, src);
        gl.compileShader(s);
        return s;
      }
      var p = gl.createProgram();
      gl.attachShader(p, sh(gl.VERTEX_SHADER, VERT));
      gl.attachShader(p, sh(gl.FRAGMENT_SHADER, FRAG));
      gl.linkProgram(p);
      return gl.getProgramParameter(p, gl.LINK_STATUS) ? p : null;
    }

    function setup(el) {
      var d = el.dataset;
      var photo = el.querySelector('img');
      if (!photo || !d.map) return;
      var canvas = document.createElement('canvas');
      canvas.className = 'dpp-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.appendChild(canvas);
      var gl = canvas.getContext('webgl', { premultipliedAlpha: false, antialias: false });
      if (!gl) return;
      var prog = program(gl);
      if (!prog) return;
      gl.useProgram(prog);
      var buf = gl.createBuffer();
      gl.bindBuffer(gl.ARRAY_BUFFER, buf);
      gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
      var loc = gl.getAttribLocation(prog, 'p');
      gl.enableVertexAttribArray(loc);
      gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
      var U = {};
      ['img', 'dep', 'off', 'sc', 'focus'].forEach(function (n) { U[n] = gl.getUniformLocation(prog, n); });

      var amount = d.amount !== undefined ? +d.amount : DEFAULTS.amount;
      var focus = d.focus !== undefined ? +d.focus : DEFAULTS.focus;
      var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
      var ratio = 1.6, ready = false, w = 0, h = 0;
      // The view: where it is now (x, y) and where it's heading (tx, ty), from -1 to 1.
      var s = { x: 0, y: 0, tx: 0, ty: 0, t: 0, held: 0 };

      function resize() {
        var dpr = Math.min(2, window.devicePixelRatio || 1);
        w = el.clientWidth;
        h = el.clientHeight;
        canvas.width = Math.max(1, Math.round(w * dpr));
        canvas.height = Math.max(1, Math.round(h * dpr));
        gl.viewport(0, 0, canvas.width, canvas.height);
      }

      function draw() {
        if (!ready || !w || !h) return;
        // Cover the box like object-fit: cover, zoomed in a touch so the edges never show.
        var box = w / h, zoom = 1 / (1 + amount * 2.4);
        var sx = box > ratio ? 1 : box / ratio, sy = box > ratio ? ratio / box : 1;
        gl.uniform2f(U.sc, sx * zoom, sy * zoom);
        gl.uniform2f(U.off, s.x * amount, s.y * amount * ratio * 0.6);
        gl.uniform1f(U.focus, focus);
        gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
      }

      Promise.all([load(photo.currentSrc || photo.src), load(d.map)]).then(function (ims) {
        ratio = (ims[0].naturalWidth || 16) / (ims[0].naturalHeight || 10);
        texture(gl, 0, raster(ims[0], 2048));
        texture(gl, 1, raster(ims[1], 1024));
        gl.uniform1i(U.img, 0);
        gl.uniform1i(U.dep, 1);
        ready = true;
        resize();
        draw();
        el.classList.add('dpp-on');
      }).catch(function () {});

      resize();
      if (window.ResizeObserver) new ResizeObserver(function () { resize(); draw(); }).observe(el);
      if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;

      if (d.pointer !== 'false') {
        var aim = function (e) {
          var r = el.getBoundingClientRect();
          s.tx = Math.max(-1, Math.min(1, ((e.clientX - r.left) / r.width) * 2 - 1));
          s.ty = Math.max(-1, Math.min(1, ((e.clientY - r.top) / r.height) * 2 - 1));
          s.held = 2.5; // seconds before it goes back to drifting
        };
        el.addEventListener('pointermove', function (e) { if (e.pointerType === 'mouse' || e.buttons) aim(e); });
        el.addEventListener('pointerdown', aim);
      }

      var last = 0;
      var timer = anime.createTimer({
        loop: true,
        duration: 1000,
        autoplay: false,
        onUpdate: function () {
          var now = performance.now();
          var dt = last ? Math.min(50, now - last) / 1000 : 0.016;
          last = now;
          if (s.held > 0) s.held -= dt;
          else {
            // A slow figure-of-eight drift.
            s.t += dt * 0.55 * speed;
            s.tx = Math.sin(s.t);
            s.ty = Math.sin(s.t * 2) * 0.55;
          }
          var k = 1 - Math.pow(0.02, dt);
          s.x += (s.tx - s.x) * k;
          s.y += (s.ty - s.y) * k;
          draw();
        }
      });
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-depth-photo-parallax]').forEach(function (el) {
        if (el._dpp) return;
        el._dpp = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-depth-photo-parallax data-map="valley-depth.png" style="aspect-ratio: 16 / 10">
  <img src="valley.jpg" alt="A valley at dusk">
</div>