WebGL Infinite Image Gallery with Concave Distortion
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.
+ and - keys.[ and ] keys.R key.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.
FORT WAYNE, Ind. (WOWO) — The state of Indiana has agreed to let the Indiana…
FORT WAYNE, Ind. (WOWO) — Severe thunderstorms are expected to move across central Indiana in…
Universal Pictures and Focus Features have taken the stage at CinemaCon. We're expecting new looks…
Maritza Montejo, a Liberty Tax Service office manager, helps Aurora Hernandez, left, with her taxes…
The Rockford Education Association is accusing Rockford Public Schools 205 of unfair labor practices. The…
Severe storms from Tuesday, April 14, caused significant damage in Pearl City, Stephenson County, including…
This website uses cookies.