WebGL Infinite Image Gallery with Concave Distortion

WebGL Infinite Image Gallery with Concave Distortion
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();
});

The post WebGL Infinite Image Gallery with Concave Distortion appeared first on CSS Script.


Discover more from RSS Feeds Cloud

Subscribe to get the latest posts sent to your email.

Leave a Reply

Your email address will not be published. Required fields are marked *

Discover more from RSS Feeds Cloud

Subscribe now to keep reading and get access to the full archive.

Continue reading