WebGL Infinite Image Gallery with Concave Distortion

Category: Gallery , Javascript | April 7, 2026
Authorol-ivier
Last UpdateApril 7, 2026
LicenseMIT
Views38 views
WebGL Infinite Image Gallery with Concave Distortion

InfinitePortraitGallery is a vanilla JavaScript class that creates an infinite, draggable image grid on a full-viewport WebGL canvas with a real-time concave distortion effect.

The effect is computed per-frame in GLSL vertex shaders, generating a fish-eye-inward (concave) warp that responds to the current mouse position and viewport size.

Features:

  • Renders the image grid on a WebGL canvas with hardware-accelerated per-vertex distortion.
  • Applies a real-time concave (inward) warp to every tile via a custom GLSL vertex shader.
  • Supports drag-to-pan on both mouse and touch devices with inertia momentum after release.
  • Accepts scroll wheel input to pan the grid horizontally and vertically.
  • Increase or decrease distortion strength in live increments using the + and - keys.
  • Widen or narrow the distortion radius with the [ and ] keys.
  • Resets all distortion parameters to their defaults with the R key.
  • Renders only the tiles inside the visible viewport area.
  • Uses a 32-subdivision mesh per tile so the curved distortion stays smooth at every zoom level.
  • Adjusts the distortion radius automatically on viewport resize.

How To Use It:

1. Code the HTML for the image gallery.

<!-- Loading mask shown before textures finish loading -->
<div class="cache"></div>
<!-- Progress text updated during image loading -->
<div class="loading">Preparing gallery... 0%</div>
<!-- Fullscreen control -->
<button class="fullscreen-btn" aria-label="Toggle fullscreen">
  <span>⛶</span>
</button>

2. Add the baseline CSS. The canvas is injected by the class itself and positioned fixed to fill the viewport.

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
  user-select: none;
}
body {
  overflow: hidden;
  background: #000;
  touch-action: none; /* required for touch drag to work correctly */
}
/* The canvas element is created by the class and appended to body */
canvas {
  display: block;
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  cursor: grab;
  touch-action: none;
}
canvas:active {
  cursor: grabbing;
}
/* Semi-transparent loading indicator centered on screen */
.loading {
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  color: #fff;
  font-size: 20px;
  z-index: 1000;
  background: rgb(0 0 0 / 0.8);
  padding: 20px 40px;
  border-radius: 10px;
  font-family: monospace;
  pointer-events: none;
}
/* Full-viewport black overlay that hides the canvas until textures load */
.cache {
  position: fixed;
  height: 100%;
  width: 100%;
  top: 0;
  left: 0;
  background-color: #000;
  z-index: 999;
}
/* Circular fullscreen button, bottom-right corner */
.fullscreen-btn {
  position: fixed;
  bottom: 20px;
  right: 20px;
  width: 44px;
  height: 44px;
  background: rgb(0 0 0 / 0.8);
  color: #fff;
  border: none;
  border-radius: 50%;
  font-size: 20px;
  cursor: pointer;
  z-index: 1000;
  display: flex;
  align-items: center;
  justify-content: center;
  backdrop-filter: blur(10px);
  transition: transform 0.2s;
}
.fullscreen-btn:hover {
  transform: scale(1.1);
}

3. Define your image URL array. The class loops through this array cyclically to fill at least 50 tiles.

// Your image sources — any array of URLs works here.
// The class cycles through this list to populate the full grid.
const MES_IMAGES = [
  "https://images.unsplash.com/photo-1506748686214-e9df14d4d9d0?w=500&h=500&fit=crop",
  "https://images.unsplash.com/photo-1519125323398-675f0ddb6308?w=500&h=500&fit=crop",
  "https://images.unsplash.com/photo-1465101162946-4377e57745c3?w=500&h=500&fit=crop",
  "https://images.unsplash.com/photo-1494526585095-c41746248156?w=500&h=500&fit=crop",
  // Add as many URLs as your project requires
];

4. Copy in the InfinitePortraitGallery class and let it instantiate on DOMContentLoaded.

class InfinitePortraitGallery {
  constructor() {
    // Create and append the WebGL canvas to <body>
    this.canvas = document.createElement('canvas');
    this.canvas.setAttribute('tabindex', '0'); // required for keyboard events
    this.canvas.style.outline = 'none';
    document.body.appendChild(this.canvas);
    // Request a WebGL context with alpha blending enabled
    this.gl = this.canvas.getContext('webgl', { alpha: true, premultipliedAlpha: false });
    if (!this.gl) {
      alert('WebGL is not supported in this browser.');
      return;
    }
    // Image state
    this.images = [];
    this.textures = [];
    // Tile dimensions and gap between cells
    this.imageWidth = 180;
    this.imageHeight = 180;
    this.gap = 25;
    // Current pan offset (world-space scroll position)
    this.viewOffset = { x: 0, y: 0 };
    // Drag state tracks mouse/touch position and velocity
    this.drag = {
      isDragging: false,
      lastX: 0,
      lastY: 0,
      velocityX: 0,
      velocityY: 0,
    };
    // Inertia factor: 0.95 = slow glide; 1.0 = no friction
    this.inertia = 0.95;
    // Shader uniforms for distortion
    this.bulgeStrength = 0.4; // 0 = flat, 1.5 = maximum concavity
    this.bulgeRadius = 1.5;   // normalized units; covers the full viewport diagonal by default
    this.adjustedBulgeRadius = this.bulgeRadius;
    this.resizeCanvas();
    window.addEventListener('resize', () => this.resizeCanvas());
    this.init();
    this.loadPortraitImages();
    this.setupEventListeners();
    this.setupFullscreen();
    this.animate();
  }
  resizeCanvas() {
    this.canvas.width = window.innerWidth;
    this.canvas.height = window.innerHeight;
    // Scale bulge radius so distortion covers the full screen on any aspect ratio
    const diagonal = Math.sqrt(
      Math.pow(this.canvas.width / Math.min(this.canvas.width, this.canvas.height), 2) +
      Math.pow(this.canvas.height / Math.min(this.canvas.width, this.canvas.height), 2)
    );
    this.adjustedBulgeRadius = Math.max(this.bulgeRadius, diagonal * 0.6 * 1.2);
    if (this.gl) this.gl.viewport(0, 0, this.canvas.width, this.canvas.height);
  }
  init() {
    // --- VERTEX SHADER ---
    // Each tile is a 32x32 subdivided quad. The vertex shader displaces vertices
    // outward from the screen center to produce the concave (inward-bowl) illusion.
    const vsSource = `
      attribute vec2 aPosition;
      attribute vec2 aTexCoord;
      varying vec2 vTexCoord;
      uniform vec2 uResolution;
      uniform vec2 uOffset;
      uniform float uRotation;
      uniform vec2 uImagePosition;
      uniform float uBulgeStrength;
      uniform float uBulgeRadius;
      vec2 applyBulgeEffect(vec2 pos) {
        vec2 normalizedPos = pos / uResolution;
        vec2 center = vec2(0.5, 0.5);
        vec2 delta = normalizedPos - center;
        // Correct for aspect ratio so the effect is circular, not elliptical
        float aspect = uResolution.x / uResolution.y;
        delta.x *= aspect;
        float dist = length(delta);
        if (dist < uBulgeRadius) {
          // Spherical concave warp: displaces vertices toward the edges
          float t = dist / uBulgeRadius;
          float z = sqrt(0.5 - t * t);
          delta *= 0.35 + uBulgeStrength / z;
          delta.x /= aspect;
          normalizedPos = center + delta;
          pos = normalizedPos * uResolution;
        }
        return pos;
      }
      void main() {
        // Scale the unit quad to tile dimensions and move to world position
        vec2 pos = aPosition * vec2(${this.imageWidth}.0, ${this.imageHeight}.0);
        pos += uImagePosition;
        pos -= uOffset;
        // Apply the concave warp in screen space
        pos = applyBulgeEffect(pos);
        // Convert pixel coordinates to WebGL clip space [-1, 1]
        vec2 clip = pos / uResolution * 2.0 - 1.0;
        gl_Position = vec4(clip, 0.0, 1.0);
        vTexCoord = aTexCoord;
      }
    `;
    // --- FRAGMENT SHADER ---
    // Samples the bound texture. Discards fully transparent pixels.
    const fsSource = `
      precision mediump float;
      varying vec2 vTexCoord;
      uniform sampler2D uSampler;
      void main() {
        // Flip Y because WebGL's texture origin is bottom-left
        vec2 uv = vec2(vTexCoord.x, 1.0 - vTexCoord.y);
        vec4 color = texture2D(uSampler, uv);
        if (color.a < 0.01) discard;
        gl_FragColor = color;
      }
    `;
    this.program = this.createProgram(vsSource, fsSource);
    // Build a 32x32 subdivided quad — more subdivisions = smoother warp curve
    const SUBDIV = 32;
    const positions = [];
    const texCoords = [];
    const indices = [];
    for (let y = 0; y <= SUBDIV; y++) {
      for (let x = 0; x <= SUBDIV; x++) {
        positions.push(x / SUBDIV, y / SUBDIV);
        texCoords.push(x / SUBDIV, y / SUBDIV);
      }
    }
    for (let y = 0; y < SUBDIV; y++) {
      for (let x = 0; x < SUBDIV; x++) {
        const i = y * (SUBDIV + 1) + x;
        indices.push(i, i + 1, i + SUBDIV + 1);
        indices.push(i + 1, i + SUBDIV + 2, i + SUBDIV + 1);
      }
    }
    this.indexCount = indices.length;
    // Upload geometry to GPU buffers
    this.positionBuffer = this.gl.createBuffer();
    this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.positionBuffer);
    this.gl.bufferData(this.gl.ARRAY_BUFFER, new Float32Array(positions), this.gl.STATIC_DRAW);
    this.texCoordBuffer = this.gl.createBuffer();
    this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.texCoordBuffer);
    this.gl.bufferData(this.gl.ARRAY_BUFFER, new Float32Array(texCoords), this.gl.STATIC_DRAW);
    this.indexBuffer = this.gl.createBuffer();
    this.gl.bindBuffer(this.gl.ELEMENT_ARRAY_BUFFER, this.indexBuffer);
    this.gl.bufferData(this.gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(indices), this.gl.STATIC_DRAW);
    // Enable alpha blending for transparent textures
    this.gl.enable(this.gl.BLEND);
    this.gl.blendFunc(this.gl.SRC_ALPHA, this.gl.ONE_MINUS_SRC_ALPHA);
  }
  async loadPortraitImages() {
    const loadingElement = document.querySelector('.loading');
    const cacheElement = document.querySelector('.cache');
    // Use the global MES_IMAGES array, or fall back to Picsum defaults
    const imageSources = typeof MES_IMAGES !== 'undefined' && MES_IMAGES.length > 0
      ? MES_IMAGES
      : this.getDefaultImages();
    // Always load at least 50 tiles to fill a large viewport comfortably
    const imageCount = Math.max(50, imageSources.length);
    const loadPromises = [];
    for (let i = 0; i < imageCount; i++) {
      const img = new Image();
      img.crossOrigin = 'Anonymous'; // required for WebGL texture upload
      img.src = imageSources[i % imageSources.length]; // cycle through sources
      const promise = new Promise((resolve) => {
        img.onload = () => {
          this.images.push(img);
          this.textures.push(this.createTexture(img));
          resolve();
        };
        img.onerror = () => {
          // Fall back to a Picsum photo if the original URL fails
          img.src = `https://picsum.photos/id/${(i % 100) + 1}/${this.imageWidth}/${this.imageHeight}`;
          img.onload = () => {
            this.images.push(img);
            this.textures.push(this.createTexture(img));
            resolve();
          };
          img.onerror = resolve; // skip broken fallbacks
        };
      });
      loadPromises.push(promise);
    }
    let loaded = 0;
    const total = loadPromises.length;
    // Sequential loading so the progress counter stays accurate
    for (const promise of loadPromises) {
      await promise;
      loaded++;
      loadingElement.textContent = `Loading... ${Math.round((loaded / total) * 100)}%`;
    }
    // Hide the cache overlay once all textures are on the GPU
    loadingElement.style.display = 'none';
    cacheElement.style.display = 'none';
  }
  getDefaultImages() {
    // A small set of Picsum IDs used when MES_IMAGES is not defined
    const portraitIds = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100];
    return portraitIds.map(id => `https://picsum.photos/id/${id}/${this.imageWidth}/${this.imageHeight}`);
  }
  createTexture(img) {
    const tex = this.gl.createTexture();
    this.gl.bindTexture(this.gl.TEXTURE_2D, tex);
    this.gl.texImage2D(this.gl.TEXTURE_2D, 0, this.gl.RGBA, this.gl.RGBA, this.gl.UNSIGNED_BYTE, img);
    // CLAMP_TO_EDGE prevents texture bleeding at tile borders
    this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_S, this.gl.CLAMP_TO_EDGE);
    this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_T, this.gl.CLAMP_TO_EDGE);
    this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MIN_FILTER, this.gl.LINEAR);
    this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MAG_FILTER, this.gl.LINEAR);
    return tex;
  }
  getVisibleTiles() {
    const tiles = [];
    const tileW = this.imageWidth + this.gap;
    const tileH = this.imageHeight + this.gap;
    // Compute the visible world-space rectangle with a one-tilebuffer
    const visibleLeft   = this.viewOffset.x - this.canvas.width;
    const visibleRight  = this.viewOffset.x + this.canvas.width * 2;
    const visibleTop    = this.viewOffset.y - this.canvas.height;
    const visibleBottom = this.viewOffset.y + this.canvas.height * 2;
    for (let y = Math.floor(visibleTop / tileH) - 1; y <= Math.ceil(visibleBottom / tileH) + 1; y++) {
      for (let x = Math.floor(visibleLeft / tileW) - 1; x <= Math.ceil(visibleRight / tileW) + 1; x++) {
        // Hash grid coordinates to a texture index — produces an irregular but stable layout
        const hash = (x * 7919 + y * 7307) % this.images.length;
        const idx = Math.abs(hash);
        tiles.push({ x: x * tileW, y: y * tileH, imageIndex: idx });
      }
    }
    return tiles;
  }
  render() {
    if (!this.program || this.images.length === 0) return;
    this.gl.viewport(0, 0, this.canvas.width, this.canvas.height);
    this.gl.clearColor(0, 0, 0, 0);
    this.gl.clear(this.gl.COLOR_BUFFER_BIT);
    this.gl.useProgram(this.program);
    // Bind position buffer and configure its vertex attribute
    const posLoc = this.gl.getAttribLocation(this.program, 'aPosition');
    this.gl.enableVertexAttribArray(posLoc);
    this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.positionBuffer);
    this.gl.vertexAttribPointer(posLoc, 2, this.gl.FLOAT, false, 0, 0);
    // Bind UV / texture coordinate buffer
    const texLoc = this.gl.getAttribLocation(this.program, 'aTexCoord');
    this.gl.enableVertexAttribArray(texLoc);
    this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.texCoordBuffer);
    this.gl.vertexAttribPointer(texLoc, 2, this.gl.FLOAT, false, 0, 0);
    this.gl.bindBuffer(this.gl.ELEMENT_ARRAY_BUFFER, this.indexBuffer);
    // Upload viewport size and distortion uniforms once per frame
    this.gl.uniform2f(this.gl.getUniformLocation(this.program, 'uResolution'), this.canvas.width, this.canvas.height);
    this.gl.uniform1f(this.gl.getUniformLocation(this.program, 'uBulgeStrength'), this.bulgeStrength);
    this.gl.uniform1f(this.gl.getUniformLocation(this.program, 'uBulgeRadius'), this.adjustedBulgeRadius);
    const offsetLoc = this.gl.getUniformLocation(this.program, 'uOffset');
    const imgPosLoc = this.gl.getUniformLocation(this.program, 'uImagePosition');
    const samplerLoc = this.gl.getUniformLocation(this.program, 'uSampler');
    // Draw each visible tile with its own texture and world position
    for (const tile of this.getVisibleTiles()) {
      this.gl.uniform2f(offsetLoc, this.viewOffset.x, this.viewOffset.y);
      this.gl.uniform2f(imgPosLoc, tile.x, tile.y);
      this.gl.activeTexture(this.gl.TEXTURE0);
      this.gl.bindTexture(this.gl.TEXTURE_2D, this.textures[tile.imageIndex]);
      this.gl.uniform1i(samplerLoc, 0);
      this.gl.drawElements(this.gl.TRIANGLES, this.indexCount, this.gl.UNSIGNED_SHORT, 0);
    }
  }
  setupEventListeners() {
    // Focus the canvas on interaction so keyboard events register
    this.canvas.addEventListener('click', () => this.canvas.focus());
    this.canvas.addEventListener('touchstart', () => this.canvas.focus());
    // Mouse drag start
    this.canvas.addEventListener('mousedown', (e) => {
      e.preventDefault();
      this.drag.isDragging = true;
      this.drag.lastX = e.clientX;
      this.drag.lastY = e.clientY;
      this.canvas.style.cursor = 'grabbing';
    });
    // Mouse drag move — blend velocity for smooth inertia
    window.addEventListener('mousemove', (e) => {
      if (!this.drag.isDragging) return;
      e.preventDefault();
      const dx = e.clientX - this.drag.lastX;
      const dy = e.clientY - this.drag.lastY;
      this.drag.velocityX = dx * 0.3 + this.drag.velocityX * 0.7;
      this.drag.velocityY = dy * 0.3 + this.drag.velocityY * 0.7;
      this.viewOffset.x -= this.drag.velocityX;
      this.viewOffset.y -= this.drag.velocityY;
      this.drag.lastX = e.clientX;
      this.drag.lastY = e.clientY;
    });
    window.addEventListener('mouseup', () => {
      this.drag.isDragging = false;
      this.canvas.style.cursor = 'grab';
    });
    // Touch drag — mirrors mouse handling for mobile
    this.canvas.addEventListener('touchstart', (e) => {
      e.preventDefault();
      this.drag.isDragging = true;
      this.drag.lastX = e.touches[0].clientX;
      this.drag.lastY = e.touches[0].clientY;
    });
    window.addEventListener('touchmove', (e) => {
      if (!this.drag.isDragging) return;
      e.preventDefault();
      const dx = e.touches[0].clientX - this.drag.lastX;
      const dy = e.touches[0].clientY - this.drag.lastY;
      this.drag.velocityX = dx * 0.3 + this.drag.velocityX * 0.7;
      this.drag.velocityY = dy * 0.3 + this.drag.velocityY * 0.7;
      this.viewOffset.x -= this.drag.velocityX;
      this.viewOffset.y -= this.drag.velocityY;
      this.drag.lastX = e.touches[0].clientX;
      this.drag.lastY = e.touches[0].clientY;
    });
    window.addEventListener('touchend', () => {
      this.drag.isDragging = false;
    });
    // Scroll wheel pans the grid
    this.canvas.addEventListener('wheel', (e) => {
      e.preventDefault();
      this.drag.velocityX += e.deltaX * 0.5;
      this.drag.velocityY += e.deltaY * 0.5;
    }, { passive: false });
    // Keyboard controls for live shader parameter tuning
    this.canvas.addEventListener('keydown', (e) => {
      e.preventDefault();
      switch (e.key) {
        case '+': case '=':
          // Increase concavity strength, capped at 1.5
          this.bulgeStrength = Math.min(1.5, this.bulgeStrength + 0.05);
          break;
        case '-': case '_':
          // Decrease concavity strength, floored at 0 (flat)
          this.bulgeStrength = Math.max(0, this.bulgeStrength - 0.05);
          break;
        case '[':
          // Narrow the distortion radius
          this.bulgeRadius = Math.max(0.5, this.bulgeRadius - 0.05);
          this.resizeCanvas();
          break;
        case ']':
          // Widen the distortion radius
          this.bulgeRadius = Math.min(3, this.bulgeRadius + 0.05);
          this.resizeCanvas();
          break;
        case 'r': case 'R':
          // Reset to default shader parameters
          this.bulgeStrength = 0.6;
          this.bulgeRadius = 1.5;
          this.resizeCanvas();
          break;
      }
    });
  }
  setupFullscreen() {
    const fullscreenBtn = document.querySelector('.fullscreen-btn');
    const icon = fullscreenBtn.querySelector('i');
    const toggleFullscreen = () => {
      if (!document.fullscreenElement) {
        document.documentElement.requestFullscreen().catch(err => {
          console.warn(`Fullscreen request failed: ${err.message}`);
        });
        icon.className = 'fas fa-compress';
      } else {
        document.exitFullscreen();
        icon.className = 'fas fa-expand';
      }
    };
    fullscreenBtn.addEventListener('click', toggleFullscreen);
    // Sync the icon state if the user exits fullscreen via Esc
    document.addEventListener('fullscreenchange', () => {
      icon.className = document.fullscreenElement ? 'fas fa-compress' : 'fas fa-expand';
      setTimeout(() => this.resizeCanvas(), 100); // re-measure after browser chrome reflows
    });
  }
  animate() {
    // Apply inertia when the user is not actively dragging
    if (!this.drag.isDragging) {
      this.viewOffset.x -= this.drag.velocityX;
      this.viewOffset.y -= this.drag.velocityY;
      this.drag.velocityX *= this.inertia; // decay toward zero each frame
      this.drag.velocityY *= this.inertia;
      // Stop applying tiny residual velocity to avoid constant GPU work
      if (Math.abs(this.drag.velocityX) < 0.01) this.drag.velocityX = 0;
      if (Math.abs(this.drag.velocityY) < 0.01) this.drag.velocityY = 0;
    }
    this.render();
    requestAnimationFrame(() => this.animate());
  }
  createProgram(vsSource, fsSource) {
    const vs = this.loadShader(this.gl.VERTEX_SHADER, vsSource);
    const fs = this.loadShader(this.gl.FRAGMENT_SHADER, fsSource);
    const prog = this.gl.createProgram();
    this.gl.attachShader(prog, vs);
    this.gl.attachShader(prog, fs);
    this.gl.linkProgram(prog);
    if (!this.gl.getProgramParameter(prog, this.gl.LINK_STATUS)) {
      console.error('Shader program link error:', this.gl.getProgramInfoLog(prog));
      return null;
    }
    return prog;
  }
  loadShader(type, source) {
    const shader = this.gl.createShader(type);
    this.gl.shaderSource(shader, source);
    this.gl.compileShader(shader);
    if (!this.gl.getShaderParameter(shader, this.gl.COMPILE_STATUS)) {
      console.error('Shader compile error:', this.gl.getShaderInfoLog(shader));
      this.gl.deleteShader(shader);
      return null;
    }
    return shader;
  }
}
// Boot the gallery after the DOM is ready
document.addEventListener('DOMContentLoaded', () => {
  new InfinitePortraitGallery();
});

You Might Be Interested In:


Leave a Reply