Skip to content

Instantly share code, notes, and snippets.

@krhoyt
Created March 19, 2026 20:00
Show Gist options
  • Select an option

  • Save krhoyt/4d7a3d44275bee9f7f853e30488306d3 to your computer and use it in GitHub Desktop.

Select an option

Save krhoyt/4d7a3d44275bee9f7f853e30488306d3 to your computer and use it in GitHub Desktop.
WebGL Transitions Carousel
import { WebGLTransitionFactory } from './WebGLTransitionFactory.js';
import { transitionLibrary } from './transition-library.js';
const IMAGE_URLS = [
'https://picsum.photos/id/1015/1200/800',
'https://picsum.photos/id/1025/1200/800',
'https://picsum.photos/id/1035/1200/800',
'https://picsum.photos/id/1043/1200/800'
];
// Time the shader animation itself runs.
const TRANSITION_DURATION = 2000;
// Time to wait after one transition completes before starting the next.
const TRANSITION_DELAY = 3000;
function modulo(index, length) {
return ((index % length) + length) % length;
}
async function preloadImages(factory, urls) {
const uniqueUrls = [...new Set(urls)];
const loadedPairs = await Promise.all(
uniqueUrls.map(async (url) => [url, await factory.loadImage(url)])
);
return new Map(loadedPairs);
}
async function main() {
const canvas = document.getElementById('glcanvas');
const controls = document.getElementById('controls');
const durationInput = document.getElementById('duration-input');
const playButton = document.getElementById('play-button');
const transitions = new WebGLTransitionFactory(canvas, {
duration: TRANSITION_DURATION,
bgcolor: [0.0, 0.0, 0.0, 1.0]
});
transitions.registerMany(transitionLibrary);
const availableTransitionNames = transitionLibrary.map((transition) => transition.name);
if (IMAGE_URLS.length === 0) {
throw new Error('IMAGE_URLS must contain at least one image URL.');
}
if (availableTransitionNames.length === 0) {
throw new Error('transitionLibrary must contain at least one transition.');
}
const imageCache = await preloadImages(transitions, IMAGE_URLS);
let currentImageIndex = 0;
let currentTransitionIndex = 0;
let carouselTimeoutId = null;
let isCarouselRunning = true;
async function showImagePair(fromIndex, toIndex) {
const normalizedFromIndex = modulo(fromIndex, IMAGE_URLS.length);
const normalizedToIndex = modulo(toIndex, IMAGE_URLS.length);
const fromUrl = IMAGE_URLS[normalizedFromIndex];
const toUrl = IMAGE_URLS[normalizedToIndex];
const fromImage = imageCache.get(fromUrl);
const toImage = imageCache.get(toUrl);
if (!fromImage || !toImage) {
throw new Error('One or more carousel images failed to preload.');
}
transitions.setTextures(fromImage, toImage);
}
function applyCurrentTransition() {
const transitionName =
availableTransitionNames[modulo(currentTransitionIndex, availableTransitionNames.length)];
transitions.setTransition(transitionName);
}
async function advanceCarousel() {
if (!isCarouselRunning) return;
const nextImageIndex = modulo(currentImageIndex + 1, IMAGE_URLS.length);
await showImagePair(currentImageIndex, nextImageIndex);
applyCurrentTransition();
transitions.playOnce();
currentImageIndex = nextImageIndex;
currentTransitionIndex = modulo(
currentTransitionIndex + 1,
availableTransitionNames.length
);
carouselTimeoutId = window.setTimeout(() => {
advanceCarousel().catch((err) => {
console.error(err);
});
}, transitions.runtimeState.duration + TRANSITION_DELAY);
}
function stopCarousel() {
isCarouselRunning = false;
if (carouselTimeoutId !== null) {
window.clearTimeout(carouselTimeoutId);
carouselTimeoutId = null;
}
}
function startCarousel() {
stopCarousel();
isCarouselRunning = true;
advanceCarousel().catch((err) => {
console.error(err);
});
}
// Initial state: show the first image transitioning to the second.
await showImagePair(0, IMAGE_URLS.length > 1 ? 1 : 0);
applyCurrentTransition();
transitions.mountButtons(controls);
transitions.start();
transitions.playOnce();
durationInput.value = String(TRANSITION_DURATION);
durationInput.addEventListener('change', () => {
transitions.setDuration(durationInput.value);
startCarousel();
});
durationInput.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
transitions.setDuration(durationInput.value);
startCarousel();
}
});
playButton.addEventListener('click', () => {
transitions.setDuration(durationInput.value);
startCarousel();
});
window.addEventListener('beforeunload', () => {
stopCarousel();
transitions.stop();
});
// Start the automated carousel loop after the initial transition.
carouselTimeoutId = window.setTimeout(() => {
advanceCarousel().catch((err) => {
console.error(err);
});
}, transitions.runtimeState.duration + TRANSITION_DELAY);
window.transitions = transitions;
window.startCarousel = startCarousel;
window.stopCarousel = stopCarousel;
// Optional convenience helpers
window.setAngularSpeed = (value) => {
transitions.setTransitionParams('angular', {
speed: Number(value)
});
};
window.setCrossZoomIntensity = (value) => {
transitions.setTransitionParams('cross-zoom', {
intensity: Number(value)
});
};
window.setDirection = (x, y) => {
transitions.setTransitionParams('slide-wrap', {
direction: [Number(x), Number(y)]
});
};
window.setStripeCount = (value) => {
transitions.setTransitionParams('vertical-stripes', {
count: Number(value)
});
};
window.setStripeSmoothness = (value) => {
transitions.setTransitionParams('vertical-stripes', {
smoothness: Number(value)
});
};
window.setGridSize = (x, y) => {
transitions.setTransitionParams('random-squares', {
size: [Number(x), Number(y)]
});
};
window.setDots = (value) => {
transitions.setTransitionParams('dot-reveal', {
dots: Number(value)
});
};
window.setCenter = (x, y) => {
transitions.setTransitionParams('dot-reveal', {
center: [Number(x), Number(y)]
});
};
window.setDoorway = (partial) => {
transitions.setTransitionParams('doorway', partial);
};
window.setHexSteps = (value) => {
transitions.setTransitionParams('hexagonalize', {
steps: Number(value)
});
};
window.setHorizontalHexagons = (value) => {
transitions.setTransitionParams('hexagonalize', {
horizontalHexagons: Number(value)
});
};
window.setPageCurl = (partial) => {
transitions.setTransitionParams('page-curl', partial);
};
window.setRadialStartingAngle = (value) => {
transitions.setTransitionParams('radial', {
startingAngle: Number(value)
});
};
window.setSwap = (partial) => {
transitions.setTransitionParams('swap', partial);
};
window.setSquaresWire = (partial) => {
transitions.setTransitionParams('squares-wire', partial);
};
}
main().catch((err) => {
console.error(err);
document.body.innerHTML =
'<pre style="color:white;padding:16px;">' + err.message + '</pre>';
});
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>WebGL Transition Library</title>
<style>
html, body {
margin: 0;
height: 100%;
background: #111;
overflow: hidden;
font-family: sans-serif;
}
canvas {
display: block;
width: 100vw;
height: 100vh;
}
.controls {
position: fixed;
top: 12px;
left: 12px;
z-index: 10;
display: flex;
gap: 8px;
padding: 8px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.45);
color: white;
flex-wrap: wrap;
align-items: center;
max-width: calc(100vw - 24px);
}
button,
input {
font: 14px/1.2 sans-serif;
}
label {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
input[type="number"] {
width: 90px;
}
</style>
</head>
<body>
<div class="controls" id="controls">
<label>
Duration (ms)
<input id="duration-input" type="number" min="1" step="100" value="2000" />
</label>
<button id="play-button" type="button">Play</button>
</div>
<canvas id="glcanvas"></canvas>
<script type="module" src="./app.js"></script>
</body>
</html>
export const transitionLibrary = [
{
name: 'angular',
label: 'Angular',
defaults: {
speed: 2.0
},
uniforms: {
speed: { type: '1f', getValue: (params) => params.speed }
},
glsl: `
vec4 transition(vec2 uv) {
vec2 p = uv;
float circPos = atan(p.y - 0.5, p.x - 0.5) + progress * speed;
float modPos = mod(circPos, 3.1415 / 4.0);
float signedValue = sign(progress - modPos);
return mix(
getToColor(p),
getFromColor(p),
step(signedValue, 0.5)
);
}
`
},
{
name: 'crossfade',
label: 'Crossfade',
defaults: {},
uniforms: {},
glsl: `
vec4 transition(vec2 p) {
return mix(getFromColor(p), getToColor(p), progress);
}
`
},
{
name: 'cross-zoom',
label: 'Cross Zoom',
defaults: {
intensity: 0.1
},
uniforms: {
intensity: { type: '1f', getValue: (params) => params.intensity }
},
glsl: `
const int passes = 6;
vec4 transition(vec2 uv) {
vec4 c1 = vec4(0.0);
vec4 c2 = vec4(0.0);
float disp = intensity * (0.5 - distance(0.5, progress));
for (int xi = 0; xi < passes; xi++) {
float x = float(xi) / float(passes) - 0.5;
for (int yi = 0; yi < passes; yi++) {
float y = float(yi) / float(passes) - 0.5;
vec2 v = vec2(x, y);
float d = disp;
c1 += getFromColor(uv + d * v);
c2 += getToColor(uv + d * v);
}
}
c1 /= float(passes * passes);
c2 /= float(passes * passes);
return mix(c1, c2, progress);
}
`
},
{
name: 'doorway',
label: 'Doorway',
defaults: {
reflection: 0.4,
perspective: 0.4,
depth: 3.0
},
uniforms: {
reflection: { type: '1f', getValue: (params) => params.reflection },
perspective: { type: '1f', getValue: (params) => params.perspective },
depth: { type: '1f', getValue: (params) => params.depth }
},
glsl: `
const vec4 black = vec4(0.0, 0.0, 0.0, 1.0);
const vec2 boundMin = vec2(0.0, 0.0);
const vec2 boundMax = vec2(1.0, 1.0);
bool inBounds(vec2 p) {
return all(lessThan(boundMin, p)) && all(lessThan(p, boundMax));
}
vec2 project(vec2 p) {
return p * vec2(1.0, -1.2) + vec2(0.0, -0.02);
}
vec4 bgColor(vec2 p, vec2 pto) {
vec4 c = black;
pto = project(pto);
if (inBounds(pto)) {
c += mix(
black,
getToColor(pto),
reflection * mix(1.0, 0.0, pto.y)
);
}
return c;
}
vec4 transition(vec2 p) {
vec2 pfr = vec2(-1.0);
vec2 pto = vec2(-1.0);
float middleSlit = 2.0 * abs(p.x - 0.5) - progress;
if (middleSlit > 0.0) {
pfr = p + (p.x > 0.5 ? -1.0 : 1.0) * vec2(0.5 * progress, 0.0);
float d = 1.0 / (1.0 + perspective * progress * (1.0 - middleSlit));
pfr.y -= d / 2.0;
pfr.y *= d;
pfr.y += d / 2.0;
}
float size = mix(1.0, depth, 1.0 - progress);
pto = (p - vec2(0.5)) * vec2(size) + vec2(0.5);
if (inBounds(pfr)) {
return getFromColor(pfr);
} else if (inBounds(pto)) {
return getToColor(pto);
} else {
return bgColor(p, pto);
}
}
`
},
{
name: 'slide-wrap',
label: 'Slide Wrap',
defaults: {
direction: [0.0, 1.0]
},
uniforms: {
direction: { type: '2f', getValue: (params) => params.direction }
},
glsl: `
vec4 transition(vec2 p) {
vec2 shifted = p + progress * sign(direction);
vec2 wrapped = fract(shifted);
float inBounds =
step(0.0, shifted.y) *
step(shifted.y, 1.0) *
step(0.0, shifted.x) *
step(shifted.x, 1.0);
return mix(
getToColor(wrapped),
getFromColor(wrapped),
inBounds
);
}
`
},
{
name: 'swap',
label: 'Swap',
defaults: {
reflection: 0.4,
perspective: 0.2,
depth: 3.0
},
uniforms: {
reflection: { type: '1f', getValue: (params) => params.reflection },
perspective: { type: '1f', getValue: (params) => params.perspective },
depth: { type: '1f', getValue: (params) => params.depth }
},
glsl: `
const vec4 black = vec4(0.0, 0.0, 0.0, 1.0);
const vec2 boundMin = vec2(0.0, 0.0);
const vec2 boundMax = vec2(1.0, 1.0);
bool inBounds(vec2 p) {
return all(lessThan(boundMin, p)) && all(lessThan(p, boundMax));
}
vec2 project(vec2 p) {
return p * vec2(1.0, -1.2) + vec2(0.0, -0.02);
}
vec4 bgColor(vec2 p, vec2 pfr, vec2 pto) {
vec4 c = black;
pfr = project(pfr);
if (inBounds(pfr)) {
c += mix(
black,
getFromColor(pfr),
reflection * mix(1.0, 0.0, pfr.y)
);
}
pto = project(pto);
if (inBounds(pto)) {
c += mix(
black,
getToColor(pto),
reflection * mix(1.0, 0.0, pto.y)
);
}
return c;
}
vec4 transition(vec2 p) {
vec2 pfr = vec2(-1.0);
vec2 pto = vec2(-1.0);
float size = mix(1.0, depth, progress);
float persp = perspective * progress;
pfr = (p + vec2(0.0, -0.5)) *
vec2(
size / (1.0 - perspective * progress),
size / (1.0 - size * persp * p.x)
) + vec2(0.0, 0.5);
size = mix(1.0, depth, 1.0 - progress);
persp = perspective * (1.0 - progress);
pto = (p + vec2(-1.0, -0.5)) *
vec2(
size / (1.0 - perspective * (1.0 - progress)),
size / (1.0 - size * persp * (0.5 - p.x))
) + vec2(1.0, 0.5);
if (progress < 0.5) {
if (inBounds(pfr)) {
return getFromColor(pfr);
}
if (inBounds(pto)) {
return getToColor(pto);
}
}
if (inBounds(pto)) {
return getToColor(pto);
}
if (inBounds(pfr)) {
return getFromColor(pfr);
}
return bgColor(p, pfr, pto);
}
`
},
{
name: 'squares-wire',
label: 'Squares Wire',
defaults: {
squares: [10.0, 10.0],
direction: [1.0, -0.5],
smoothness: 1.6
},
uniforms: {
squares: { type: '2f', getValue: (params) => params.squares },
direction: { type: '2f', getValue: (params) => params.direction },
smoothness: { type: '1f', getValue: (params) => params.smoothness }
},
glsl: `
const vec2 center = vec2(0.5, 0.5);
vec4 transition(vec2 p) {
vec2 v = normalize(direction);
v /= abs(v.x) + abs(v.y);
float d = v.x * center.x + v.y * center.y;
float offset = smoothness;
float pr = smoothstep(
-offset,
0.0,
v.x * p.x + v.y * p.y - (d - 0.5 + progress * (1.0 + offset))
);
vec2 squarep = fract(p * squares);
vec2 squaremin = vec2(pr / 2.0);
vec2 squaremax = vec2(1.0 - pr / 2.0);
float a =
(1.0 - step(progress, 0.0)) *
step(squaremin.x, squarep.x) *
step(squaremin.y, squarep.y) *
step(squarep.x, squaremax.x) *
step(squarep.y, squaremax.y);
return mix(getFromColor(p), getToColor(p), a);
}
`
},
{
name: 'vertical-stripes',
label: 'Vertical Stripes',
defaults: {
count: 10.0,
smoothness: 0.5
},
uniforms: {
count: { type: '1f', getValue: (params) => params.count },
smoothness: { type: '1f', getValue: (params) => params.smoothness }
},
glsl: `
vec4 transition(vec2 p) {
float pr = smoothstep(
-smoothness,
0.0,
p.x - progress * (1.0 + smoothness)
);
float s = step(pr, fract(count * p.x));
return mix(getFromColor(p), getToColor(p), s);
}
`
},
{
name: 'radial',
label: 'Radial',
defaults: {
startingAngle: 90.0
},
uniforms: {
startingAngle: { type: '1f', getValue: (params) => params.startingAngle }
},
glsl: `
#define PI 3.141592653589
vec4 transition(vec2 uv) {
float offset = startingAngle * PI / 180.0;
float angle = atan(uv.y - 0.5, uv.x - 0.5) + offset;
float normalizedAngle = (angle + PI) / (2.0 * PI);
normalizedAngle = normalizedAngle - floor(normalizedAngle);
return mix(
getFromColor(uv),
getToColor(uv),
step(normalizedAngle, progress)
);
}
`
},
{
name: 'random-squares',
label: 'Random Squares',
defaults: {
size: [10.0, 10.0],
smoothness: 0.5
},
uniforms: {
size: { type: '2f', getValue: (params) => params.size },
smoothness: { type: '1f', getValue: (params) => params.smoothness }
},
glsl: `
float rand(vec2 co) {
return fract(sin(dot(co.xy, vec2(12.9898, 78.233))) * 43758.5453);
}
vec4 transition(vec2 p) {
float r = rand(floor(size * p));
float m = smoothstep(
0.0,
-smoothness,
r - (progress * (1.0 + smoothness))
);
return mix(getFromColor(p), getToColor(p), m);
}
`
},
{
name: 'dot-reveal',
label: 'Dot Reveal',
defaults: {
dots: 20.0,
center: [0.0, 0.0]
},
uniforms: {
dots: { type: '1f', getValue: (params) => params.dots },
center: { type: '2f', getValue: (params) => params.center }
},
glsl: `
vec4 transition(vec2 p) {
float d = max(distance(p, center), 0.0001);
bool nextImage =
distance(fract(p * dots), vec2(0.5, 0.5)) <
(progress / d);
return nextImage ? getToColor(p) : getFromColor(p);
}
`
},
{
name: 'hexagonalize',
label: 'Hexagonalize',
defaults: {
steps: 50.0,
horizontalHexagons: 20.0
},
uniforms: {
steps: { type: '1f', getValue: (params) => params.steps },
horizontalHexagons: { type: '1f', getValue: (params) => params.horizontalHexagons }
},
glsl: `
struct Hexagon {
float q;
float r;
float s;
};
Hexagon createHexagon(float q, float r) {
Hexagon hex;
hex.q = q;
hex.r = r;
hex.s = -q - r;
return hex;
}
Hexagon roundHexagon(Hexagon hex) {
float q = floor(hex.q + 0.5);
float r = floor(hex.r + 0.5);
float s = floor(hex.s + 0.5);
float deltaQ = abs(q - hex.q);
float deltaR = abs(r - hex.r);
float deltaS = abs(s - hex.s);
if (deltaQ > deltaR && deltaQ > deltaS) {
q = -r - s;
} else if (deltaR > deltaS) {
r = -q - s;
} else {
s = -q - r;
}
return createHexagon(q, r);
}
Hexagon hexagonFromPoint(vec2 point, float size) {
point.y /= ratio;
point = (point - 0.5) / size;
float q = (sqrt(3.0) / 3.0) * point.x + (-1.0 / 3.0) * point.y;
float r = 2.0 / 3.0 * point.y;
Hexagon hex = createHexagon(q, r);
return roundHexagon(hex);
}
vec2 pointFromHexagon(Hexagon hex, float size) {
float x = (sqrt(3.0) * hex.q + (sqrt(3.0) / 2.0) * hex.r) * size + 0.5;
float y = ((3.0 / 2.0) * hex.r) * size + 0.5;
return vec2(x, y * ratio);
}
vec4 transition(vec2 p) {
float dist = 2.0 * min(progress, 1.0 - progress);
if (steps > 0.0) {
dist = ceil(dist * steps) / steps;
}
float size = (sqrt(3.0) / 3.0) * dist / horizontalHexagons;
vec2 point = dist > 0.0
? pointFromHexagon(hexagonFromPoint(p, size), size)
: p;
return mix(getFromColor(point), getToColor(point), progress);
}
`
},
{
name: 'cylinder-curl',
label: 'Cylinder Curl',
defaults: {},
uniforms: {},
glsl: `
const float MIN_AMOUNT = -0.16;
const float MAX_AMOUNT = 1.5;
const float PI = 3.141592653589793;
const float scale = 512.0;
const float sharpness = 3.0;
const float angle = 100.0 * PI / 180.0;
const float cylinderRadius = 1.0 / PI / 2.0;
vec3 hitPoint(float hitAngle, float yc, vec3 point, mat3 rrotation) {
float hitPointValue = hitAngle / (2.0 * PI);
point.y = hitPointValue;
return rrotation * point;
}
vec4 antiAlias(vec4 color1, vec4 color2, float distanc) {
distanc *= scale;
if (distanc < 0.0) return color2;
if (distanc > 2.0) return color1;
float dd = pow(1.0 - distanc / 2.0, sharpness);
return ((color2 - color1) * dd) + color1;
}
float distanceToEdge(vec3 point) {
float dx = abs(point.x > 0.5 ? 1.0 - point.x : point.x);
float dy = abs(point.y > 0.5 ? 1.0 - point.y : point.y);
if (point.x < 0.0) dx = -point.x;
if (point.x > 1.0) dx = point.x - 1.0;
if (point.y < 0.0) dy = -point.y;
if (point.y > 1.0) dy = point.y - 1.0;
if ((point.x < 0.0 || point.x > 1.0) && (point.y < 0.0 || point.y > 1.0)) {
return sqrt(dx * dx + dy * dy);
}
return min(dx, dy);
}
vec4 seeThrough(
float yc,
vec2 p,
mat3 rotation,
mat3 rrotation,
float cylinderAngle
) {
float hitAngle = PI - (acos(yc / cylinderRadius) - cylinderAngle);
vec3 point = hitPoint(hitAngle, yc, rotation * vec3(p, 1.0), rrotation);
if (yc <= 0.0 && (point.x < 0.0 || point.y < 0.0 || point.x > 1.0 || point.y > 1.0)) {
return getToColor(p);
}
if (yc > 0.0) return getFromColor(p);
vec4 color = getFromColor(point.xy);
vec4 tcolor = vec4(0.0);
return antiAlias(color, tcolor, distanceToEdge(point));
}
vec4 seeThroughWithShadow(
float yc,
vec2 p,
vec3 point,
mat3 rotation,
mat3 rrotation,
float amount,
float cylinderAngle
) {
float shadow = distanceToEdge(point) * 30.0;
shadow = (1.0 - shadow) / 3.0;
if (shadow < 0.0) {
shadow = 0.0;
} else {
shadow *= amount;
}
vec4 shadowColor = seeThrough(yc, p, rotation, rrotation, cylinderAngle);
shadowColor.r -= shadow;
shadowColor.g -= shadow;
shadowColor.b -= shadow;
return shadowColor;
}
vec4 backside(float yc, vec3 point) {
vec4 color = getFromColor(point.xy);
float gray = (color.r + color.b + color.g) / 15.0;
gray += (8.0 / 10.0) * (
pow(1.0 - abs(yc / cylinderRadius), 2.0 / 10.0) / 2.0 + (5.0 / 10.0)
);
color.rgb = vec3(gray);
return color;
}
vec4 behindSurface(
vec2 p,
float yc,
vec3 point,
mat3 rrotation,
float amount,
float cylinderAngle
) {
float shado = (1.0 - ((-cylinderRadius - yc) / amount * 7.0)) / 6.0;
shado *= 1.0 - abs(point.x - 0.5);
yc = (-cylinderRadius - cylinderRadius - yc);
float hitAngle = (acos(yc / cylinderRadius) + cylinderAngle) - PI;
point = hitPoint(hitAngle, yc, point, rrotation);
if (
yc < 0.0 &&
point.x >= 0.0 &&
point.y >= 0.0 &&
point.x <= 1.0 &&
point.y <= 1.0 &&
(hitAngle < PI || amount > 0.5)
) {
shado = 1.0 - (
sqrt(pow(point.x - 0.5, 2.0) + pow(point.y - 0.5, 2.0)) / (71.0 / 100.0)
);
shado *= pow(-yc / cylinderRadius, 3.0);
shado *= 0.5;
} else {
shado = 0.0;
}
return vec4(getToColor(p).rgb - shado, 1.0);
}
vec4 transition(vec2 p) {
float amount = progress * (MAX_AMOUNT - MIN_AMOUNT) + MIN_AMOUNT;
float cylinderCenter = amount;
float cylinderAngle = 2.0 * PI * amount;
float c = cos(-angle);
float s = sin(-angle);
mat3 rotation = mat3(
c, s, 0.0,
-s, c, 0.0,
-0.801, 0.8900, 1.0
);
c = cos(angle);
s = sin(angle);
mat3 rrotation = mat3(
c, s, 0.0,
-s, c, 0.0,
0.9850, 0.985, 1.0
);
vec3 point = rotation * vec3(p, 1.0);
float yc = point.y - cylinderCenter;
if (yc < -cylinderRadius) {
return behindSurface(p, yc, point, rrotation, amount, cylinderAngle);
}
if (yc > cylinderRadius) {
return getFromColor(p);
}
float hitAngle = (acos(yc / cylinderRadius) + cylinderAngle) - PI;
float hitAngleMod = mod(hitAngle, 2.0 * PI);
if (
(hitAngleMod > PI && amount < 0.5) ||
(hitAngleMod > PI / 2.0 && amount < 0.0)
) {
return seeThrough(yc, p, rotation, rrotation, cylinderAngle);
}
point = hitPoint(hitAngle, yc, point, rrotation);
if (point.x < 0.0 || point.y < 0.0 || point.x > 1.0 || point.y > 1.0) {
return seeThroughWithShadow(yc, p, point, rotation, rrotation, amount, cylinderAngle);
}
vec4 color = backside(yc, point);
vec4 otherColor;
if (yc < 0.0) {
float shado = 1.0 - (
sqrt(pow(point.x - 0.5, 2.0) + pow(point.y - 0.5, 2.0)) / 0.71
);
shado *= pow(-yc / cylinderRadius, 3.0);
shado *= 0.5;
otherColor = vec4(0.0, 0.0, 0.0, shado);
} else {
otherColor = getFromColor(p);
}
color = antiAlias(color, otherColor, cylinderRadius - abs(yc));
vec4 cl = seeThroughWithShadow(yc, p, point, rotation, rrotation, amount, cylinderAngle);
float dist = distanceToEdge(point);
return antiAlias(color, cl, dist);
}
`
},
{
name: 'page-curl',
label: 'Page Curl',
defaults: {
persp: 0.7,
unzoom: 0.3,
reflection: 0.4,
floating: 3.0
},
uniforms: {
persp: { type: '1f', getValue: (params) => params.persp },
unzoom: { type: '1f', getValue: (params) => params.unzoom },
reflection: { type: '1f', getValue: (params) => params.reflection },
floating: { type: '1f', getValue: (params) => params.floating }
},
glsl: `
vec2 project(vec2 p) {
return p * vec2(1.0, -1.2) + vec2(0.0, -floating / 100.0);
}
bool inBounds(vec2 p) {
return all(lessThan(vec2(0.0), p)) && all(lessThan(p, vec2(1.0)));
}
vec4 bgColor(vec2 p, vec2 pfr, vec2 pto) {
vec4 c = vec4(0.0, 0.0, 0.0, 1.0);
pfr = project(pfr);
if (inBounds(pfr)) {
c += mix(vec4(0.0), getFromColor(pfr), reflection * mix(1.0, 0.0, pfr.y));
}
pto = project(pto);
if (inBounds(pto)) {
c += mix(vec4(0.0), getToColor(pto), reflection * mix(1.0, 0.0, pto.y));
}
return c;
}
vec2 xskew(vec2 p, float perspValue, float center) {
float x = mix(p.x, 1.0 - p.x, center);
return (
(
vec2(
x,
(p.y - 0.5 * (1.0 - perspValue) * x) /
(1.0 + (perspValue - 1.0) * x)
) - vec2(0.5 - distance(center, 0.5), 0.0)
) * vec2(
0.5 / distance(center, 0.5) * (center < 0.5 ? 1.0 : -1.0),
1.0
) + vec2(center < 0.5 ? 0.0 : 1.0, 0.0)
);
}
vec4 transition(vec2 op) {
float t = clamp(progress, 0.0001, 0.9999);
float uz = unzoom * 2.0 * (0.5 - distance(0.5, t));
vec2 p = -uz * 0.5 + (1.0 + uz) * op;
vec2 fromP = xskew(
(p - vec2(t, 0.0)) / vec2(1.0 - t, 1.0),
1.0 - mix(t, 0.0, persp),
0.0
);
vec2 toP = xskew(
p / vec2(t, 1.0),
mix(pow(t, 2.0), 1.0, persp),
1.0
);
if (inBounds(fromP)) {
return getFromColor(fromP);
} else if (inBounds(toP)) {
return getToColor(toP);
}
return bgColor(op, fromP, toP);
}
`
}
];
export class WebGLTransitionFactory {
static vertexSource = `
attribute vec2 a_position;
attribute vec2 a_uv;
varying vec2 v_uv;
void main() {
v_uv = a_uv;
gl_Position = vec4(a_position, 0.0, 1.0);
}
`;
static fragmentHeader = `
precision mediump float;
uniform sampler2D u_from;
uniform sampler2D u_to;
uniform float progress;
uniform float ratio;
uniform vec4 bgcolor;
varying vec2 v_uv;
vec4 getFromColor(vec2 p) {
return texture2D(u_from, p);
}
vec4 getToColor(vec2 p) {
return texture2D(u_to, p);
}
`;
static sharedUniforms = {
u_from: { type: '1i' },
u_to: { type: '1i' },
progress: { type: '1f' },
ratio: { type: '1f' },
bgcolor: { type: '4f' }
};
constructor(canvas, options = {}) {
this.canvas = canvas;
this.gl = canvas.getContext('webgl');
if (!this.gl) {
throw new Error('WebGL not supported');
}
this.runtimeState = {
duration: options.duration ?? 2000,
bgcolor: options.bgcolor ?? [0.0, 0.0, 0.0, 1.0]
};
this.transitionDefs = [];
this.transitions = [];
this.transitionMap = new Map();
this.transitionOverrides = new Map();
this.active = null;
this.activeParams = {};
this.startTime = null;
this.playing = true;
this.rafId = null;
this.buffer = null;
this.textureFrom = null;
this.textureTo = null;
this._initGeometry();
this._render = this._render.bind(this);
}
_initGeometry() {
const gl = this.gl;
const vertices = new Float32Array([
-1, -1, 0, 0,
1, -1, 1, 0,
-1, 1, 0, 1,
-1, 1, 0, 1,
1, -1, 1, 0,
1, 1, 1, 1
]);
this.buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
}
_createShader(type, source) {
const gl = this.gl;
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
const info = gl.getShaderInfoLog(shader);
gl.deleteShader(shader);
throw new Error('Shader compile error: ' + info + '\n\n' + source);
}
return shader;
}
_createProgram(vertexSource, fragmentSource) {
const gl = this.gl;
const vs = this._createShader(gl.VERTEX_SHADER, vertexSource);
const fs = this._createShader(gl.FRAGMENT_SHADER, fragmentSource);
const program = gl.createProgram();
gl.attachShader(program, vs);
gl.attachShader(program, fs);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
const info = gl.getProgramInfoLog(program);
gl.deleteProgram(program);
throw new Error('Program link error: ' + info);
}
return program;
}
_buildFragmentSource(def) {
const uniformLines = Object.entries(def.uniforms || {})
.map(([name, spec]) => {
const glslTypeMap = {
'1f': 'float',
'1i': 'int',
'2f': 'vec2',
'3f': 'vec3',
'4f': 'vec4'
};
return `uniform ${glslTypeMap[spec.type]} ${name};`;
})
.join('\n');
return `
${WebGLTransitionFactory.fragmentHeader}
${uniformLines}
${def.glsl}
void main() {
gl_FragColor = transition(v_uv);
}
`;
}
_setUniform(location, type, value) {
if (location === null) return;
const gl = this.gl;
switch (type) {
case '1f':
gl.uniform1f(location, value);
break;
case '1i':
gl.uniform1i(location, value);
break;
case '2f':
gl.uniform2f(location, value[0], value[1]);
break;
case '3f':
gl.uniform3f(location, value[0], value[1], value[2]);
break;
case '4f':
gl.uniform4f(location, value[0], value[1], value[2], value[3]);
break;
default:
throw new Error('Unsupported uniform type: ' + type);
}
}
_bindAttributes(locations) {
const gl = this.gl;
gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
const stride = 4 * Float32Array.BYTES_PER_ELEMENT;
if (locations.a_position !== -1) {
gl.enableVertexAttribArray(locations.a_position);
gl.vertexAttribPointer(locations.a_position, 2, gl.FLOAT, false, stride, 0);
}
if (locations.a_uv !== -1) {
gl.enableVertexAttribArray(locations.a_uv);
gl.vertexAttribPointer(
locations.a_uv,
2,
gl.FLOAT,
false,
stride,
2 * Float32Array.BYTES_PER_ELEMENT
);
}
}
_resizeCanvas() {
const gl = this.gl;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const width = Math.floor(this.canvas.clientWidth * dpr);
const height = Math.floor(this.canvas.clientHeight * dpr);
if (this.canvas.width !== width || this.canvas.height !== height) {
this.canvas.width = width;
this.canvas.height = height;
}
gl.viewport(0, 0, this.canvas.width, this.canvas.height);
}
_mergeParams(defaults, overrides) {
return {
...structuredClone(defaults || {}),
...(overrides || {})
};
}
_createTransition(def) {
const gl = this.gl;
const fragmentSource = this._buildFragmentSource(def);
const program = this._createProgram(
WebGLTransitionFactory.vertexSource,
fragmentSource
);
const locations = {
a_position: gl.getAttribLocation(program, 'a_position'),
a_uv: gl.getAttribLocation(program, 'a_uv')
};
for (const name of Object.keys(WebGLTransitionFactory.sharedUniforms)) {
locations[name] = gl.getUniformLocation(program, name);
}
for (const name of Object.keys(def.uniforms || {})) {
locations[name] = gl.getUniformLocation(program, name);
}
return {
name: def.name,
label: def.label || def.name,
defaults: structuredClone(def.defaults || {}),
program,
locations,
bind: (runtimeState, params, runtime) => {
this._setUniform(locations.u_from, '1i', 0);
this._setUniform(locations.u_to, '1i', 1);
this._setUniform(locations.progress, '1f', runtime.progress);
this._setUniform(locations.ratio, '1f', runtime.ratio);
this._setUniform(locations.bgcolor, '4f', runtimeState.bgcolor);
for (const [name, spec] of Object.entries(def.uniforms || {})) {
const value = spec.getValue(params, runtime, runtimeState);
this._setUniform(locations[name], spec.type, value);
}
}
};
}
register(def) {
if (!def || !def.name) {
throw new Error('Transition definition must include a name.');
}
if (this.transitionMap.has(def.name)) {
throw new Error(`Transition "${def.name}" is already registered.`);
}
const transition = this._createTransition(def);
this.transitionDefs.push(def);
this.transitions.push(transition);
this.transitionMap.set(transition.name, transition);
if (!this.active) {
this.active = transition;
this.activeParams = this._mergeParams(
transition.defaults,
this.transitionOverrides.get(transition.name)
);
}
return this;
}
registerMany(definitions) {
definitions.forEach((def) => this.register(def));
return this;
}
setTransition(indexOrName) {
let next = null;
if (typeof indexOrName === 'number') {
if (indexOrName >= 0 && indexOrName < this.transitions.length) {
next = this.transitions[indexOrName];
}
} else {
next = this.transitionMap.get(indexOrName) || null;
}
if (!next) return this;
this.active = next;
this.activeParams = this._mergeParams(
next.defaults,
this.transitionOverrides.get(next.name)
);
this.playOnce();
return this;
}
setTransitionParams(name, partial) {
const transition = this.transitionMap.get(name);
if (!transition) return this;
const previous = this.transitionOverrides.get(name) || {};
this.transitionOverrides.set(name, { ...previous, ...partial });
if (this.active && this.active.name === name) {
this.activeParams = this._mergeParams(
this.active.defaults,
this.transitionOverrides.get(name)
);
}
return this;
}
getTransitionParams(name) {
const transition = this.transitionMap.get(name);
if (!transition) return null;
return this._mergeParams(
transition.defaults,
this.transitionOverrides.get(name)
);
}
setDuration(ms) {
const value = Number(ms);
if (!Number.isFinite(value) || value <= 0) return this;
this.runtimeState.duration = value;
return this;
}
setBackgroundColor(r, g, b, a = 1.0) {
this.runtimeState.bgcolor = [r, g, b, a];
return this;
}
playOnce(durationOverride) {
if (durationOverride !== undefined) {
this.setDuration(durationOverride);
}
this.startTime = null;
this.playing = true;
return this;
}
async loadImage(url) {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('Failed to load image: ' + url));
img.src = url;
});
}
_createTexture(image, textureUnit) {
const gl = this.gl;
const texture = gl.createTexture();
gl.activeTexture(gl.TEXTURE0 + textureUnit);
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
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,
image
);
return texture;
}
setTextures(fromImage, toImage) {
this.textureFrom = this._createTexture(fromImage, 0);
this.textureTo = this._createTexture(toImage, 1);
return this;
}
async setTexturesFromUrls(fromUrl, toUrl) {
const [fromImage, toImage] = await Promise.all([
this.loadImage(fromUrl),
this.loadImage(toUrl)
]);
this.setTextures(fromImage, toImage);
return this;
}
mountButtons(container) {
container.querySelectorAll('[data-transition-button="true"]').forEach((node) => {
node.remove();
});
this.transitions.forEach((transition, index) => {
const button = document.createElement('button');
button.type = 'button';
button.textContent = transition.label;
button.dataset.transitionButton = 'true';
button.addEventListener('click', () => this.setTransition(index));
container.appendChild(button);
});
return this;
}
start() {
if (!this.active) {
throw new Error('No transitions registered.');
}
if (!this.rafId) {
this.rafId = requestAnimationFrame(this._render);
}
return this;
}
stop() {
if (this.rafId) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
return this;
}
_render(time) {
const gl = this.gl;
this._resizeCanvas();
if (this.playing && this.startTime === null) {
this.startTime = time;
}
let progress = 0.0;
if (this.playing) {
const elapsed = time - this.startTime;
progress = Math.min(elapsed / this.runtimeState.duration, 1.0);
if (progress >= 1.0) {
this.playing = false;
}
} else {
progress = 1.0;
}
const runtime = {
progress,
ratio: this.canvas.width / this.canvas.height
};
gl.clearColor(
this.runtimeState.bgcolor[0],
this.runtimeState.bgcolor[1],
this.runtimeState.bgcolor[2],
this.runtimeState.bgcolor[3]
);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(this.active.program);
this._bindAttributes(this.active.locations);
this.active.bind(this.runtimeState, this.activeParams, runtime);
gl.drawArrays(gl.TRIANGLES, 0, 6);
this.rafId = requestAnimationFrame(this._render);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment