CSSとJSで作る、ガラス・波紋エフェクトで切り替わるスライダー

ビジュアル

CSSとJSで作る、ガラス・波紋エフェクトで切り替わるスライダー

投稿日2026/02/13

更新日2026/2/6

CSSだけでは表現しきれない、よりリッチな画像切り替えを実現したい。
このスライダーは、Three.jsとフラグメントシェーダーを使って、ガラス・フロスト・リップルなどのビジュアルエフェクトをかけながらスライドを切り替える構成になっています。

UI部分はCSSでシンプルにまとめ、描画と演出はすべてWebGL側に集約しています。

Preview プレビュー

Code コード

<main class="slider-wrapper">
	<canvas class="webgl-canvas"></canvas>
	<nav class="slides-navigation" id="slidesNav"></nav>
</main>
@import url("https://fonts.cdnfonts.com/css/pp-neue-montreal");
@font-face {
	font-family: "PPSupplyMono";
	src: url("https://assets.codepen.io/7558/PPSupplyMono-Regular.ttf") format("truetype");
	font-weight: normal;
	font-style: normal;
	font-display: swap;
}
:root {
	--font-mono: "PPSupplyMono", monospace;
	--font-sans: "PP Neue Montreal", sans-serif;
	--color-bg: #000;
	--color-text: #fff;
	--color-text-muted: rgba(255, 255, 255, 0.8);
	--color-text-light: rgba(255, 255, 255, 0.6);
	--color-accent: #fff;
	--font-size-mono: clamp(10px, 1.2vw, 12px);
	--spacing-sm: 1rem;
	--spacing-md: 2rem;
}
* {
	margin: 0;
	padding: 0;
	box-sizing: border-box;
}
body {
	font-family: var(--font-sans);
	background: var(--color-bg);
	overflow: hidden;
	color: var(--color-text);
	cursor: pointer;
}
.slider-wrapper {
	position: relative;
	width: 100vw;
	height: 100vh;
	overflow: hidden;
	opacity: 1;
	pointer-events: auto;
}
.webgl-canvas {
	display: block;
	width: 100%;
	height: 100%;
}
.slides-navigation {
	position: absolute;
	bottom: var(--spacing-md);
	left: var(--spacing-md);
	right: var(--spacing-md);
	display: flex;
	gap: 0;
	z-index: 3;
	pointer-events: all;
}
.slide-nav-item {
	display: flex;
	flex-direction: column;
	cursor: pointer;
	padding: var(--spacing-sm);
	flex: 1;
	border: none;
	background: none;
}
.slide-progress-line {
	width: 100%;
	height: 2px;
	background: rgba(255, 255, 255, 0.2);
	margin-bottom: 8px;
	border-radius: 1px;
	overflow: hidden;
}
.slide-progress-fill {
	height: 100%;
	width: 0%;
	background: var(--color-accent);
	transition: width 0.1s ease, opacity 0.3s ease;
	border-radius: 1px;
}
.slide-nav-title {
	font-family: var(--font-mono);
	font-size: 11px;
	text-transform: uppercase;
	letter-spacing: 0.5px;
	color: var(--color-text-muted);
	font-weight: 600;
	transition: color 0.3s ease;
}
.slide-nav-item.active .slide-nav-title {
	color: var(--color-text);
}
.tp-dfwv {
	position: fixed !important;
	top: 20px !important;
	right: 20px !important;
	z-index: 1000 !important;
	max-width: 320px !important;
	background: rgba(0, 0, 0, 0.9) !important;
	backdrop-filter: blur(20px) !important;
	border: 1px solid rgba(255, 255, 255, 0.1) !important;
	border-radius: 8px !important;
}
@media (max-width: 600px) {
	.slides-navigation {
		bottom: var(--spacing-sm);
		left: var(--spacing-sm);
		right: var(--spacing-sm);
	}
	.slide-nav-item {
		padding: 0.75rem;
	}
}
import * as THREE from "https://esm.sh/three";
import {
	Pane
} from "https://cdn.skypack.dev/tweakpane@4.0.4";
import gsap from "https://esm.sh/gsap";
const SLIDER_CONFIG = {
	settings: {
		transitionDuration: 2.5,
		autoSlideSpeed: 5000,
		currentEffect: "glass",
		currentEffectPreset: "Default",
		globalIntensity: 1.0,
		speedMultiplier: 1.0,
		distortionStrength: 1.0,
		colorEnhancement: 1.0,
		glassRefractionStrength: 1.0,
		glassChromaticAberration: 1.0,
		glassBubbleClarity: 1.0,
		glassEdgeGlow: 1.0,
		glassLiquidFlow: 1.0,
		frostIntensity: 1.5,
		frostCrystalSize: 1.0,
		frostIceCoverage: 1.0,
		frostTemperature: 1.0,
		frostTexture: 1.0,
		rippleFrequency: 25.0,
		rippleAmplitude: 0.08,
		rippleWaveSpeed: 1.0,
		rippleRippleCount: 1.0,
		rippleDecay: 1.0,
		plasmaIntensity: 1.2,
		plasmaSpeed: 0.8,
		plasmaEnergyIntensity: 0.4,
		plasmaContrastBoost: 0.3,
		plasmaTurbulence: 1.0,
		timeshiftDistortion: 1.6,
		timeshiftBlur: 1.5,
		timeshiftFlow: 1.4,
		timeshiftChromatic: 1.5,
		timeshiftTurbulence: 1.4
	},
	effectPresets: {
		glass: {
			Subtle: {
				glassRefractionStrength: 0.6,
				glassChromaticAberration: 0.5,
				glassBubbleClarity: 1.3,
				glassEdgeGlow: 0.7,
				glassLiquidFlow: 0.8
			},
			Default: {
				glassRefractionStrength: 1.0,
				glassChromaticAberration: 1.0,
				glassBubbleClarity: 1.0,
				glassEdgeGlow: 1.0,
				glassLiquidFlow: 1.0
			},
			Crystal: {
				glassRefractionStrength: 1.5,
				glassChromaticAberration: 1.8,
				glassBubbleClarity: 0.7,
				glassEdgeGlow: 1.4,
				glassLiquidFlow: 0.5
			},
			Liquid: {
				glassRefractionStrength: 0.8,
				glassChromaticAberration: 0.4,
				glassBubbleClarity: 1.2,
				glassEdgeGlow: 0.8,
				glassLiquidFlow: 1.8
			}
		},
		frost: {
			Light: {
				frostIntensity: 0.8,
				frostCrystalSize: 1.3,
				frostIceCoverage: 0.6,
				frostTemperature: 0.7,
				frostTexture: 0.8
			},
			Default: {
				frostIntensity: 1.5,
				frostCrystalSize: 1.0,
				frostIceCoverage: 1.0,
				frostTemperature: 1.0,
				frostTexture: 1.0
			},
			Heavy: {
				frostIntensity: 2.2,
				frostCrystalSize: 0.7,
				frostIceCoverage: 1.4,
				frostTemperature: 1.5,
				frostTexture: 1.3
			},
			Arctic: {
				frostIntensity: 2.8,
				frostCrystalSize: 0.5,
				frostIceCoverage: 1.8,
				frostTemperature: 2.0,
				frostTexture: 1.6
			}
		},
		ripple: {
			Gentle: {
				rippleFrequency: 15.0,
				rippleAmplitude: 0.05,
				rippleWaveSpeed: 0.7,
				rippleRippleCount: 0.8,
				rippleDecay: 1.2
			},
			Default: {
				rippleFrequency: 25.0,
				rippleAmplitude: 0.08,
				rippleWaveSpeed: 1.0,
				rippleRippleCount: 1.0,
				rippleDecay: 1.0
			},
			Strong: {
				rippleFrequency: 35.0,
				rippleAmplitude: 0.12,
				rippleWaveSpeed: 1.4,
				rippleRippleCount: 1.3,
				rippleDecay: 0.8
			},
			Tsunami: {
				rippleFrequency: 45.0,
				rippleAmplitude: 0.18,
				rippleWaveSpeed: 1.8,
				rippleRippleCount: 1.6,
				rippleDecay: 0.6
			}
		},
		plasma: {
			Calm: {
				plasmaIntensity: 0.8,
				plasmaSpeed: 0.5,
				plasmaEnergyIntensity: 0.2,
				plasmaContrastBoost: 0.1,
				plasmaTurbulence: 0.6
			},
			Default: {
				plasmaIntensity: 1.2,
				plasmaSpeed: 0.8,
				plasmaEnergyIntensity: 0.4,
				plasmaContrastBoost: 0.3,
				plasmaTurbulence: 1.0
			},
			Storm: {
				plasmaIntensity: 1.8,
				plasmaSpeed: 1.3,
				plasmaEnergyIntensity: 0.7,
				plasmaContrastBoost: 0.5,
				plasmaTurbulence: 1.5
			},
			Nuclear: {
				plasmaIntensity: 2.5,
				plasmaSpeed: 1.8,
				plasmaEnergyIntensity: 1.0,
				plasmaContrastBoost: 0.8,
				plasmaTurbulence: 2.0
			}
		},
		timeshift: {
			Subtle: {
				timeshiftDistortion: 0.5,
				timeshiftBlur: 0.6,
				timeshiftFlow: 0.5,
				timeshiftChromatic: 0.4,
				timeshiftTurbulence: 0.6
			},
			Default: {
				timeshiftDistortion: 1.6,
				timeshiftBlur: 1.5,
				timeshiftFlow: 1.4,
				timeshiftChromatic: 1.5,
				timeshiftTurbulence: 1.4
			},
			Intense: {
				timeshiftDistortion: 2.2,
				timeshiftBlur: 2.0,
				timeshiftFlow: 2.0,
				timeshiftChromatic: 2.2,
				timeshiftTurbulence: 2.0
			},
			Dreamlike: {
				timeshiftDistortion: 2.8,
				timeshiftBlur: 2.5,
				timeshiftFlow: 2.5,
				timeshiftChromatic: 2.6,
				timeshiftTurbulence: 2.5
			}
		}
	}
};
let currentSlideIndex = 0;
let isTransitioning = false;
let shaderMaterial, renderer, scene, camera;
let slideTextures = [];
let autoSlideTimer = null;
let progressAnimation = null;
let sliderEnabled = false;
let pane = null;
let isApplyingPreset = false;
let effectFolders = {};
const SLIDE_DURATION = () => SLIDER_CONFIG.settings.autoSlideSpeed;
const PROGRESS_UPDATE_INTERVAL = 50;
const TRANSITION_DURATION = () => SLIDER_CONFIG.settings.transitionDuration;
// タイトルを数字に変更
const slides = [{
	title: "01",
	media: "https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?auto=format&fit=crop&w=2000&q=80"
}, {
	title: "02",
	media: "https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?auto=format&fit=crop&w=2000&q=80"
}, {
	title: "03",
	media: "https://images.unsplash.com/photo-1550684848-fac1c5b4e853?auto=format&fit=crop&w=2000&q=80"
}, {
	title: "04",
	media: "https://images.unsplash.com/photo-1473580044384-7ba9967e16a0?auto=format&fit=crop&w=2000&q=80"
}, {
	title: "05",
	media: "https://images.unsplash.com/photo-1551244072-5d12893278ab?auto=format&fit=crop&w=2000&q=80"
}, {
	title: "06",
	media: "https://images.unsplash.com/photo-1441974231531-c6227db76b6e?auto=format&fit=crop&w=2000&q=80"
}];
const vertexShader = `
        varying vec2 vUv;
        void main() {
            vUv = uv;
            gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
        }
    `;
const fragmentShader = `
        uniform sampler2D uTexture1;
        uniform sampler2D uTexture2;
        uniform float uProgress;
        uniform vec2 uResolution;
        uniform vec2 uTexture1Size;
        uniform vec2 uTexture2Size;
        uniform int uEffectType;
        
        uniform float uGlobalIntensity;
        uniform float uSpeedMultiplier;
        uniform float uDistortionStrength;
        
        uniform float uGlassRefractionStrength;
        uniform float uGlassChromaticAberration;
        uniform float uGlassBubbleClarity;
        uniform float uGlassLiquidFlow;
        
        uniform float uFrostIntensity;
        uniform float uFrostIceCoverage;
        
        uniform float uRippleFrequency;
        uniform float uRippleAmplitude;
        uniform float uRippleWaveSpeed;
        uniform float uRippleDecay;
        
        uniform float uPlasmaIntensity;
        uniform float uPlasmaSpeed;
        uniform float uPlasmaEnergyIntensity;
        
        uniform float uTimeshiftDistortion;
        
        varying vec2 vUv;

        vec2 getCoverUV(vec2 uv, vec2 textureSize) {
            vec2 s = uResolution / textureSize;
            float scale = max(s.x, s.y);
            vec2 scaledSize = textureSize * scale;
            vec2 offset = (uResolution - scaledSize) * 0.5;
            return (uv * uResolution - offset) / scaledSize;
        }

        float noise(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
        float smoothNoise(vec2 p) {
            vec2 i = floor(p); vec2 f = fract(p);
            f = f * f * (3.0 - 2.0 * f);
            return mix(mix(noise(i), noise(i + vec2(1.0, 0.0)), f.x), mix(noise(i + vec2(0.0, 1.0)), noise(i + vec2(1.0, 1.0)), f.x), f.y);
        }
        float rand(vec2 uv) { return fract(sin(dot(uv, vec2(92., 80.))) + cos(dot(uv, vec2(41., 62.))) * 51.); }

        vec4 glassEffect(vec2 uv, float progress) {
            float glassStrength = 0.08 * uGlassRefractionStrength * uDistortionStrength * uGlobalIntensity;
            float liquidFlow = 0.015 * uGlassLiquidFlow * uSpeedMultiplier;
            vec2 center = vec2(0.5, 0.5);
            vec2 p = uv * uResolution;
            vec2 uv1 = getCoverUV(uv, uTexture1Size);
            vec2 uv2_base = getCoverUV(uv, uTexture2Size);
            float maxRadius = length(uResolution) * 0.85;
            float bubbleRadius = progress * maxRadius;
            vec2 sphereCenter = center * uResolution;
            float dist = length(p - sphereCenter);
            float normalizedDist = dist / max(bubbleRadius, 0.001);
            float inside = smoothstep(bubbleRadius + 3.0, bubbleRadius - 3.0, dist);
            float distanceFactor = smoothstep(0.3 * uGlassBubbleClarity, 1.0, normalizedDist);
            float time = progress * 5.0 * uSpeedMultiplier;
            vec2 direction = (dist > 0.0) ? (p - sphereCenter) / dist : vec2(0.0);
            vec2 distortedUV = uv2_base;
            if (inside > 0.0) {
                float refractionOffset = glassStrength * pow(distanceFactor, 1.5);
                distortedUV -= normalize(direction + vec2(sin(time), cos(time * 0.7)) * 0.3) * refractionOffset;
                distortedUV += vec2(sin(time + normalizedDist * 10.0), cos(time * 0.8 + normalizedDist * 8.0)) * liquidFlow * distanceFactor * inside;
            }
            return mix(texture2D(uTexture1, uv1), texture2D(uTexture2, distortedUV), inside);
        }

        vec4 frostEffect(vec2 uv, float progress) {
            vec4 currentImg = texture2D(uTexture1, getCoverUV(uv, uTexture1Size));
            float size = progress * 1.12 * uFrostIceCoverage + 0.0001;
            float dist = distance(uv, vec2(0.5));
            float vignette = pow(1.0 - smoothstep(size, size * 2.0, dist), 2.0);
            float frost = (smoothNoise(uv * 80.0) + smoothNoise(uv * 40.0)) * 0.5;
            vec2 rnd = vec2(rand(uv + frost * 0.1), rand(uv + frost * 0.1 + 0.5)) * frost * vignette * uFrostIntensity;
            vec4 frozen = texture2D(uTexture2, getCoverUV(uv + rnd * 0.06, uTexture2Size));
            return mix(currentImg, frozen, smoothstep(0.0, 1.0, progress));
        }

        vec4 rippleEffect(vec2 uv, float progress) {
            vec2 center = vec2(0.5);
            float dist = distance(uv, center);
            float waveRadius = progress * 1.2 * uRippleWaveSpeed;
            float ripple = sin((dist - waveRadius) * uRippleFrequency) * exp(-abs(dist - waveRadius) * 8.0 * uRippleDecay) * uRippleAmplitude * uGlobalIntensity;
            vec2 distortedUV = getCoverUV(uv + normalize(uv - center) * ripple, uTexture2Size);
            return mix(texture2D(uTexture1, getCoverUV(uv, uTexture1Size)), texture2D(uTexture2, distortedUV), smoothstep(0.0, 1.0, progress));
        }

        vec4 plasmaEffect(vec2 uv, float progress) {
            float time = progress * 8.0 * uPlasmaSpeed;
            float plasma = sin(uv.x * 10.0 + time) * cos(uv.y * 8.0 + time) + smoothNoise(uv * 15.0);
            vec2 dist = vec2(sin(plasma * 6.28), cos(plasma * 6.28)) * 0.02 * uPlasmaIntensity * uGlobalIntensity;
            vec4 res = mix(texture2D(uTexture1, getCoverUV(uv + dist, uTexture1Size)), texture2D(uTexture2, getCoverUV(uv + dist, uTexture2Size)), progress);
            return res + vec4(0.1, 0.15, 0.2, 0.0) * abs(plasma) * uPlasmaEnergyIntensity;
        }

        vec4 timeshiftEffect(vec2 uv, float progress) {
            vec2 center = vec2(0.5);
            float circleRadius = progress * length(uResolution) * 0.85;
            float dist = length((uv * uResolution) - (center * uResolution));
            float inside = smoothstep(circleRadius * 1.2, circleRadius * 0.8, dist);
            float turb = smoothNoise(uv * 20.0 + progress * 5.0);
            vec2 displacement = vec2(turb - 0.5) * 0.1 * uTimeshiftDistortion * uGlobalIntensity;
            return mix(texture2D(uTexture1, getCoverUV(uv + displacement, uTexture1Size)), texture2D(uTexture2, getCoverUV(uv + displacement, uTexture2Size)), inside);
        }

        void main() {
            if (uEffectType == 0) gl_FragColor = glassEffect(vUv, uProgress);
            else if (uEffectType == 1) gl_FragColor = frostEffect(vUv, uProgress);
            else if (uEffectType == 2) gl_FragColor = rippleEffect(vUv, uProgress);
            else if (uEffectType == 3) gl_FragColor = plasmaEffect(vUv, uProgress);
            else gl_FragColor = timeshiftEffect(vUv, uProgress);
        }
    `;
const getEffectIndex = (name) => ({
	glass: 0,
	frost: 1,
	ripple: 2,
	plasma: 3,
	timeshift: 4
} [name] || 0);
const setupPane = () => {
	pane = new Pane({
		title: "Visual Effects Controls"
	});
	const general = pane.addFolder({
		title: "General Settings"
	});
	general.addBinding(SLIDER_CONFIG.settings, "globalIntensity", {
		min: 0.1,
		max: 2.0
	});
	general.addBinding(SLIDER_CONFIG.settings, "speedMultiplier", {
		min: 0.1,
		max: 3.0
	});
	general.addBinding(SLIDER_CONFIG.settings, "transitionDuration", {
		min: 0.5,
		max: 5.0
	});
	const effectSelect = pane.addFolder({
		title: "Effect Selection"
	});
	effectSelect.addBinding(SLIDER_CONFIG.settings, "currentEffect", {
		options: {
			Glass: "glass",
			Frost: "frost",
			Ripple: "ripple",
			Plasma: "plasma",
			Timeshift: "timeshift"
		}
	}).on("change", (v) => handleEffectChange(v.value));
	setupEffectFolders();
	updateEffectFolderVisibility(SLIDER_CONFIG.settings.currentEffect);
	pane.on("change", () => {
		if (!isApplyingPreset) updateShaderUniforms();
	});
	const paneElement = document.querySelector(".tp-dfwv");
	if (paneElement) paneElement.style.display = "none";
};
const setupEffectFolders = () => {
	effectFolders.glass = pane.addFolder({
		title: "Glass Settings"
	});
	effectFolders.glass.addBinding(SLIDER_CONFIG.settings, "glassRefractionStrength", {
		min: 0.1,
		max: 3.0
	});
	effectFolders.frost = pane.addFolder({
		title: "Frost Settings"
	});
	effectFolders.frost.addBinding(SLIDER_CONFIG.settings, "frostIntensity", {
		min: 0.5,
		max: 3.0
	});
	effectFolders.ripple = pane.addFolder({
		title: "Ripple Settings"
	});
	effectFolders.ripple.addBinding(SLIDER_CONFIG.settings, "rippleFrequency", {
		min: 10,
		max: 50
	});
	effectFolders.plasma = pane.addFolder({
		title: "Plasma Settings"
	});
	effectFolders.plasma.addBinding(SLIDER_CONFIG.settings, "plasmaIntensity", {
		min: 0.5,
		max: 3.0
	});
	effectFolders.timeshift = pane.addFolder({
		title: "Timeshift Settings"
	});
	effectFolders.timeshift.addBinding(SLIDER_CONFIG.settings, "timeshiftDistortion", {
		min: 0.3,
		max: 3.0
	});
};
const updateEffectFolderVisibility = (curr) => {
	Object.keys(effectFolders).forEach(k => effectFolders[k].hidden = k !== curr);
};
const handleEffectChange = (val) => {
	if (shaderMaterial) shaderMaterial.uniforms.uEffectType.value = getEffectIndex(val);
	updateEffectFolderVisibility(val);
	updateShaderUniforms();
};
const updateShaderUniforms = () => {
	if (!shaderMaterial) return;
	const u = shaderMaterial.uniforms;
	const s = SLIDER_CONFIG.settings;
	Object.keys(s).forEach(k => {
		const uname = "u" + k.charAt(0).toUpperCase() + k.slice(1);
		if (u[uname]) u[uname].value = s[k];
	});
};
const navigateToSlide = (idx) => {
	if (isTransitioning || idx === currentSlideIndex) return;
	stopAutoSlideTimer();
	const nextTex = slideTextures[idx];
	isTransitioning = true;
	shaderMaterial.uniforms.uTexture2.value = nextTex;
	shaderMaterial.uniforms.uTexture2Size.value = nextTex.userData.size;
	gsap.to(shaderMaterial.uniforms.uProgress, {
		value: 1,
		duration: TRANSITION_DURATION(),
		ease: "power2.inOut",
		onComplete: () => {
			shaderMaterial.uniforms.uProgress.value = 0;
			shaderMaterial.uniforms.uTexture1.value = nextTex;
			shaderMaterial.uniforms.uTexture1Size.value = nextTex.userData.size;
			currentSlideIndex = idx;
			updateNavigationState(idx);
			isTransitioning = false;
			safeStartTimer(100);
		}
	});
};
const updateNavigationState = (idx) => {
	document.querySelectorAll(".slide-nav-item").forEach((n, i) => n.classList.toggle("active", i === idx));
};
const startAutoSlideTimer = () => {
	if (!sliderEnabled) return;
	let progress = 0;
	const inc = (100 / SLIDE_DURATION()) * PROGRESS_UPDATE_INTERVAL;
	progressAnimation = setInterval(() => {
		progress += inc;
		const fill = document.querySelectorAll(".slide-progress-fill")[currentSlideIndex];
		if (fill) fill.style.width = `${progress}%`;
		if (progress >= 100) {
			clearInterval(progressAnimation);
			if (!isTransitioning) navigateToSlide((currentSlideIndex + 1) % slides.length);
		}
	}, PROGRESS_UPDATE_INTERVAL);
};
const stopAutoSlideTimer = () => {
	if (progressAnimation) clearInterval(progressAnimation);
	if (autoSlideTimer) clearTimeout(autoSlideTimer);
	document.querySelectorAll(".slide-progress-fill").forEach(f => {
		f.style.width = "0%";
	});
};
const safeStartTimer = (d = 0) => {
	stopAutoSlideTimer();
	if (d > 0) autoSlideTimer = setTimeout(() => startAutoSlideTimer(), d);
	else startAutoSlideTimer();
};
const initializeRenderer = async () => {
	const canvas = document.querySelector(".webgl-canvas");
	scene = new THREE.Scene();
	camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
	renderer = new THREE.WebGLRenderer({
		canvas,
		antialias: false
	});
	renderer.setSize(window.innerWidth, window.innerHeight);
	shaderMaterial = new THREE.ShaderMaterial({
		uniforms: {
			uTexture1: {
				value: null
			},
			uTexture2: {
				value: null
			},
			uProgress: {
				value: 0.0
			},
			uResolution: {
				value: new THREE.Vector2(window.innerWidth, window.innerHeight)
			},
			uTexture1Size: {
				value: new THREE.Vector2(1, 1)
			},
			uTexture2Size: {
				value: new THREE.Vector2(1, 1)
			},
			uEffectType: {
				value: getEffectIndex(SLIDER_CONFIG.settings.currentEffect)
			},
			uGlobalIntensity: {
				value: 1.0
			},
			uSpeedMultiplier: {
				value: 1.0
			},
			uDistortionStrength: {
				value: 1.0
			},
			uGlassRefractionStrength: {
				value: 1.0
			},
			uGlassChromaticAberration: {
				value: 1.0
			},
			uGlassBubbleClarity: {
				value: 1.0
			},
			uGlassLiquidFlow: {
				value: 1.0
			},
			uFrostIntensity: {
				value: 1.5
			},
			uFrostIceCoverage: {
				value: 1.0
			},
			uRippleFrequency: {
				value: 25.0
			},
			uRippleAmplitude: {
				value: 0.08
			},
			uRippleWaveSpeed: {
				value: 1.0
			},
			uRippleDecay: {
				value: 1.0
			},
			uPlasmaIntensity: {
				value: 1.2
			},
			uPlasmaSpeed: {
				value: 0.8
			},
			uPlasmaEnergyIntensity: {
				value: 0.4
			},
			uTimeshiftDistortion: {
				value: 1.6
			}
		},
		vertexShader,
		fragmentShader
	});
	scene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), shaderMaterial));
	const loader = new THREE.TextureLoader();
	for (const s of slides) {
		const tex = await new Promise(res => loader.load(s.media, t => {
			t.minFilter = t.magFilter = THREE.LinearFilter;
			t.userData = {
				size: new THREE.Vector2(t.image.width, t.image.height)
			};
			res(t);
		}));
		slideTextures.push(tex);
	}
	shaderMaterial.uniforms.uTexture1.value = slideTextures[0];
	shaderMaterial.uniforms.uTexture1Size.value = slideTextures[0].userData.size;
	sliderEnabled = true;
	safeStartTimer(500);
	const animate = () => {
		requestAnimationFrame(animate);
		renderer.render(scene, camera);
	};
	animate();
};
window.addEventListener("load", async () => {
	const nav = document.getElementById("slidesNav");
	slides.forEach((s, i) => {
		const item = document.createElement("div");
		item.className = `slide-nav-item ${i === 0 ? "active" : ""}`;
		item.innerHTML = `<div class="slide-progress-line"><div class="slide-progress-fill"></div></div><div class="slide-nav-title">${s.title}</div>`;
		item.onclick = (e) => {
			e.stopPropagation();
			navigateToSlide(i);
		};
		nav.appendChild(item);
	});
	setupPane();
	await initializeRenderer();
});
document.addEventListener("keydown", (e) => {
	if (e.code === "KeyH") {
		const p = document.querySelector(".tp-dfwv");
		if (p) p.style.display = p.style.display === "none" ? "block" : "none";
	}
	if (e.code === "Space" || e.code === "ArrowRight") navigateToSlide((currentSlideIndex + 1) % slides.length);
	if (e.code === "ArrowLeft") navigateToSlide((currentSlideIndex - 1 + slides.length) % slides.length);
});
window.addEventListener("resize", () => {
	renderer.setSize(window.innerWidth, window.innerHeight);
	shaderMaterial.uniforms.uResolution.value.set(window.innerWidth, window.innerHeight);
});
document.addEventListener("click", (e) => {
	if (!e.target.closest(".slides-navigation") && !e.target.closest(".tp-dfwv")) {
		navigateToSlide((currentSlideIndex + 1) % slides.length);
	}
});

Explanation 詳しい説明

仕様

このスライダーは、Three.jsのShaderMaterialを使い、2枚のテクスチャをフラグメントシェーダー内で補間することで実現しています。
スライド切り替え時は、現在の画像と次の画像を同時にシェーダーへ渡し、uProgress の値をアニメーションさせることで表現を切り替えています。

画面全体には正射影カメラと板ポリゴン1枚だけを使い、すべての見た目はシェーダー内で計算されます。

  • Three.js+カスタムシェーダーによる描画
  • 2枚のテクスチャをuProgressで補間
  • 正射影カメラ+フルスクリーン描画

エフェクトは種類ごとに分岐しており、ガラス・フロスト・波紋・プラズマ・歪みなどを切り替え可能です。
UI操作や自動再生の制御にはGSAPと通常のJavaScriptを使用しています。

カスタム

エフェクトの見た目は、uniformとして渡している数値を変更することで調整できます。
Tweakpaneを使ってリアルタイムに値を操作できるため、調整しながら最適な表現を探せます。

  • 切り替え時間や自動再生速度の変更
  • エフェクトごとの強度・歪み量の調整
  • エフェクトプリセットの追加・切り替え

UI部分はCSSのみで構成されているため、ナビゲーションの位置や文字サイズ、配色は自由に変更できます。

注意点

この実装はWebGLとシェーダーに強く依存しています。
そのため、GPU性能が低い端末では描画負荷が高くなる可能性があります。

また、コード量が多く構造も複雑なため、業務用途で使う場合はエフェクト数やuniformを整理することを前提にしてください。

  • WebGL非対応環境では動作しない
  • モバイル端末ではパフォーマンスに注意
  • エフェクト追加時はシェーダー側の管理が必須