
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
Rkey. - 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.
