アニメーション

CSSとThree.jsで作る、らせん状に回転する3Dスパイラルギャラリー

投稿日2026/05/07

更新日2026/5/6

Webサイトのギャラリーといえば、横並びのスライダーやグリッドレイアウトが定番ですが、もっとインパクトのある見せ方はないでしょうか。

今回紹介するのは、Three.js(WebGL)を使って画像をらせん(スパイラル)状に配置し、スクロールでぐるぐる回転させられる3Dギャラリーです。

慣性によるなめらかな動き、ドラッグによる視点の傾き、スマホのタッチ操作にも対応しており、ポートフォリオやクリエイティブ系サイトのヒーローセクションなどに活用できます。

Code コード

<div id="kumonosu-webgl-container">
	<canvas id="kumonosu-webgl-canvas"></canvas>
</div>
* {
	margin: 0;
	padding: 0;
	box-sizing: border-box;
}
body {
	font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
	background: black;
	color: white;
	min-height: 100vh;
	overflow-x: hidden;
	height: 100vh;
	overflow-y: hidden;
}
#kumonosu-webgl-container {
	position: fixed;
	top: 0;
	left: 0;
	width: 100%;
	height: 100%;
	z-index: 1;
	pointer-events: none;
}
#kumonosu-webgl-canvas {
	position: absolute;
	top: 0;
	left: 0;
	width: 100%;
	height: 100%;
	outline: none;
}
const imageUrls = ['https://picsum.photos/id/10/800/600', 'https://picsum.photos/id/11/800/600', 'https://picsum.photos/id/12/800/600', 'https://picsum.photos/id/13/800/600', 'https://picsum.photos/id/14/800/600', 'https://picsum.photos/id/15/800/600', 'https://picsum.photos/id/16/800/600', 'https://picsum.photos/id/17/800/600', 'https://picsum.photos/id/18/800/600', 'https://picsum.photos/id/19/800/600', 'https://picsum.photos/id/20/800/600', 'https://picsum.photos/id/21/800/600', 'https://picsum.photos/id/22/800/600', 'https://picsum.photos/id/23/800/600', 'https://picsum.photos/id/24/800/600', 'https://picsum.photos/id/25/800/600', 'https://picsum.photos/id/26/800/600', 'https://picsum.photos/id/27/800/600', 'https://picsum.photos/id/28/800/600', 'https://picsum.photos/id/29/800/600', 'https://picsum.photos/id/30/800/600', 'https://picsum.photos/id/31/800/600', 'https://picsum.photos/id/32/800/600', ];
const numberOfImages = imageUrls.length;
let scene, camera, renderer, spiralMesh, tiltGroup, shaderMaterial;
let scrollOffset = 0;
let isDragging = false;
let previousMousePosition = {
	x: 0,
	y: 0
};
let dragRotation = {
	x: 0,
	z: 0
};
let baseRotation = {
	x: 0,
	z: 0
};
let imageRatios = [];
let inertiaParams = {
	friction: 0.94,
	strength: 0.8,
	maxSpeed: 0.05,
	directionSmoothing: 0.92,
	scrollSensitivity: 0.0008
};
let config = {
	imageHeight: 7,
	curvature: -0.030,
	gapSize: 0,
	spiralRadius: 3.5,
	spiralTurns: 2.8 + (numberOfImages - 21) * 0.1,
	spiralHeight: 12 + (numberOfImages - 21) * 0.25,
	centerX: 0,
	centerY: 4.38,
	centerZ: 0
};
let originalPositions = [];
let targetVelocity = 0;
let currentVelocity = 0;
let lastDelta = 0;
let touchStartY = 0;
let touchLastY = 0;
let touchVelocity = 0;
let touchAcceleration = 0;
let isTouching = false;
let lastTouchTimestamp = 0;

function setupTouchControls() {
	const container = document.getElementById('kumonosu-webgl-container');
	container.style.pointerEvents = 'auto';
	container.addEventListener('touchstart', (e) => {
		e.preventDefault();
		isTouching = true;
		touchStartY = e.touches[0].clientY;
		touchLastY = touchStartY;
		touchVelocity = 0;
		touchAcceleration = 0;
		lastTouchTimestamp = performance.now();
		container.style.cursor = 'grabbing';
	}, {
		passive: false
	});
	container.addEventListener('touchmove', (e) => {
		if (!isTouching) return;
		e.preventDefault();
		const now = performance.now();
		let deltaTime = Math.min(32, now - lastTouchTimestamp);
		if (deltaTime < 1) deltaTime = 16;
		lastTouchTimestamp = now;
		const currentY = e.touches[0].clientY;
		const deltaY = currentY - touchLastY;
		const rawVelocity = deltaY * inertiaParams.scrollSensitivity * inertiaParams.strength * 0.5;
		touchVelocity = touchVelocity * 0.7 + rawVelocity * 0.3;
		let deltaScroll = deltaY * inertiaParams.scrollSensitivity * inertiaParams.strength * 0.8;
		scrollOffset += deltaScroll;
		updateUVOffset();
		touchLastY = currentY;
	}, {
		passive: false
	});
	container.addEventListener('touchend', (e) => {
		e.preventDefault();
		isTouching = false;
		container.style.cursor = 'grab';
		if (Math.abs(touchVelocity) > 0.001) {
			targetVelocity = touchVelocity * 1.2;
			targetVelocity = Math.max(-inertiaParams.maxSpeed * 1.5, Math.min(inertiaParams.maxSpeed * 1.5, targetVelocity));
		}
		touchVelocity = 0;
	});
	let touchDragStartX = 0;
	let touchDragStartY = 0;
	let isDraggingTouch = false;
	container.addEventListener('touchstart', (e) => {
		if (e.touches.length === 2) {
			isDraggingTouch = true;
			touchDragStartX = e.touches[1].clientX;
			touchDragStartY = e.touches[1].clientY;
		}
	});
	container.addEventListener('touchmove', (e) => {
		if (isDraggingTouch && e.touches.length === 2) {
			e.preventDefault();
			const dx = e.touches[1].clientX - touchDragStartX;
			const dy = e.touches[1].clientY - touchDragStartY;
			dragRotation.z += dx * 0.003;
			dragRotation.x -= dy * 0.003;
			dragRotation.x = Math.max(-0.35, Math.min(0.35, dragRotation.x));
			dragRotation.z = Math.max(-0.35, Math.min(0.35, dragRotation.z));
			tiltGroup.rotation.x = baseRotation.x + dragRotation.x;
			tiltGroup.rotation.z = baseRotation.z + dragRotation.z;
			touchDragStartX = e.touches[1].clientX;
			touchDragStartY = e.touches[1].clientY;
		}
	});
	container.addEventListener('touchend', (e) => {
		isDraggingTouch = false;
	});
}

function updateTouchInertia() {
	if (!isTouching) {
		touchVelocity *= 0.95;
		if (Math.abs(touchVelocity) > 0.0001) {
			scrollOffset += touchVelocity * 0.5;
			updateUVOffset();
		} else {
			touchVelocity = 0;
		}
	}
}

function rebuildGeometry() {
	if (!spiralMesh) return;
	const totalSlots = imageRatios.length;
	const widths = imageRatios.map(r => r * config.imageHeight);
	const totalWidth = widths.reduce((a, b) => a + b, 0);
	const segmentsW = 200 + totalSlots * 20;
	const segmentsH = 24;
	const geometry = new THREE.PlaneGeometry(totalWidth, config.imageHeight, segmentsW, segmentsH);
	const positions = geometry.attributes.position;
	const uvs = geometry.attributes.uv;
	let origX = [];
	let origY = [];
	for (let i = 0; i < positions.count; i++) {
		origX.push(positions.getX(i));
		origY.push(positions.getY(i));
	}
	let cumulative = [0];
	for (let i = 0; i < totalSlots; i++) {
		cumulative.push(cumulative[i] + widths[i] / totalWidth);
	}
	const imageRatio = 1 - config.gapSize;
	for (let i = 0; i < uvs.count; i++) {
		let u = uvs.getX(i);
		u = Math.max(0, Math.min(0.999999, u));
		let found = false;
		for (let j = 0; j < totalSlots; j++) {
			if (u >= cumulative[j] && u < cumulative[j + 1]) {
				let localU = (u - cumulative[j]) / (cumulative[j + 1] - cumulative[j]);
				if (localU > imageRatio) {
					uvs.setX(i, cumulative[j + 1] - 0.001);
				} else {
					let scaledU = localU / imageRatio;
					const edgeMargin = 0.001;
					scaledU = Math.max(edgeMargin, Math.min(1 - edgeMargin, scaledU));
					let newU = cumulative[j] + scaledU * (cumulative[j + 1] - cumulative[j]);
					uvs.setX(i, newU);
				}
				found = true;
				break;
			}
		}
		if (!found) {
			uvs.setX(i, cumulative[totalSlots] - 0.001);
		}
	}
	for (let i = 0; i < positions.count; i++) {
		const x = positions.getX(i);
		const y = positions.getY(i);
		const nx = x / (totalWidth / 2);
		const curve = config.curvature * 0.4 * (nx * nx - 1);
		positions.setXYZ(i, x, y, -curve);
	}
	originalPositions = [];
	for (let i = 0; i < positions.count; i++) {
		const x = origX[i];
		const y = origY[i];
		let t = (x + totalWidth / 2) / totalWidth;
		t = Math.max(0, Math.min(1, t));
		const angle = t * Math.PI * 2 * config.spiralTurns;
		const radius = config.spiralRadius * (1 - t * 0.12);
		let px = Math.sin(angle) * radius;
		let pz = Math.cos(angle) * radius;
		let py = (t - 0.5) * config.spiralHeight + y * 0.35;
		if (!originalPositions[i]) {
			originalPositions[i] = {
				x: px,
				y: py,
				z: pz,
				offsetX: (Math.random() - 0.5) * 0.001,
				offsetY: (Math.random() - 0.5) * 0.001,
				offsetZ: (Math.random() - 0.5) * 0.001
			};
		}
		px += originalPositions[i].offsetX;
		py += originalPositions[i].offsetY;
		pz += originalPositions[i].offsetZ;
		positions.setXYZ(i, px, py, pz);
	}
	geometry.computeVertexNormals();
	const oldGeo = spiralMesh.geometry;
	spiralMesh.geometry = geometry;
	if (oldGeo) oldGeo.dispose();
	if (shaderMaterial) {
		shaderMaterial.uniforms.gap.value = config.gapSize;
	}
	spiralMesh.position.set(config.centerX, config.centerY, config.centerZ);
}

function updateUVOffset() {
	if (!shaderMaterial) return;
	let offset = scrollOffset;
	while (offset >= 1.0) offset -= 1.0;
	while (offset < 0) offset += 1.0;
	shaderMaterial.uniforms.offset.value = offset;
}

function createMasterTexture() {
	return new Promise((resolve) => {
		const canvas = document.createElement('canvas');
		const ctx = canvas.getContext('2d');
		const baseHeight = 500;
		let loaded = 0;
		let images = [];
		imageUrls.forEach((url, idx) => {
			const img = new Image();
			img.crossOrigin = 'Anonymous';
			img.onload = () => {
				const ratio = img.naturalWidth / img.naturalHeight;
				imageRatios[idx] = ratio;
				const width = baseHeight * ratio;
				images[idx] = {
					img,
					width,
					height: baseHeight
				};
				loaded++;
				if (loaded === numberOfImages) {
					const totalWidth = images.reduce((sum, i) => sum + i.width, 0);
					canvas.width = totalWidth;
					canvas.height = baseHeight;
					ctx.fillStyle = '#000000';
					ctx.fillRect(0, 0, canvas.width, canvas.height);
					let offsetX = 0;
					images.forEach((data) => {
						if (data && data.img) {
							ctx.drawImage(data.img, offsetX, 0, data.width, data.height);
						}
						offsetX += data.width;
					});
					const tex = new THREE.CanvasTexture(canvas);
					tex.wrapS = THREE.RepeatWrapping;
					tex.wrapT = THREE.ClampToEdgeWrapping;
					tex.minFilter = THREE.LinearFilter;
					tex.magFilter = THREE.LinearFilter;
					tex.generateMipmaps = false;
					resolve(tex);
				}
			};
			img.onerror = () => {
				imageRatios[idx] = 0.8;
				loaded++;
				if (loaded === numberOfImages) {
					const tex = new THREE.CanvasTexture(canvas);
					resolve(tex);
				}
			};
			img.src = url;
		});
	});
}
async function init() {
	scene = new THREE.Scene();
	scene.background = new THREE.Color(0x000000);
	camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 1000);
	camera.position.set(0, 3.5, 9);
	renderer = new THREE.WebGLRenderer({
		canvas: document.getElementById('kumonosu-webgl-canvas'),
		antialias: true
	});
	renderer.setSize(window.innerWidth, window.innerHeight);
	const ambient = new THREE.AmbientLight(0xffffff, 0.6);
	scene.add(ambient);
	const mainLight = new THREE.DirectionalLight(0xffffff, 0.9);
	mainLight.position.set(5, 8, 5);
	scene.add(mainLight);
	tiltGroup = new THREE.Group();
	baseRotation = {
		x: -0.18,
		z: 0.12
	};
	tiltGroup.rotation.x = baseRotation.x;
	tiltGroup.rotation.z = baseRotation.z;
	scene.add(tiltGroup);
	const texture = await createMasterTexture();
	shaderMaterial = new THREE.ShaderMaterial({
		uniforms: {
			map: {
				value: texture
			},
			gap: {
				value: config.gapSize
			},
			offset: {
				value: 0.0
			}
		},
		vertexShader: `
                    varying vec2 vUv;
                    void main() {
                        vUv = uv;
                        gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
                    }
                `,
		fragmentShader: `
                    uniform sampler2D map;
                    uniform float gap;
                    uniform float offset;
                    varying vec2 vUv;
                    
                    void main() {
                        float u = vUv.x + offset;
                        
                        if (u >= 1.0) u -= 1.0;
                        if (u < 0.0) u += 1.0;
                        
                        vec4 color = texture2D(map, vec2(u, vUv.y));
                        gl_FragColor = color;
                    }
                `,
		transparent: true,
		side: THREE.DoubleSide
	});
	spiralMesh = new THREE.Mesh(new THREE.BufferGeometry(), shaderMaterial);
	spiralMesh.rotation.x = 0.35;
	spiralMesh.rotation.y = 0;
	tiltGroup.add(spiralMesh);
	onResize();
	window.addEventListener('resize', onResize);
	setupFluidInertia();
	setupDrag();
	setupArrowKeysZoom();
	setupTouchControls();
	animate();
}

function setupFluidInertia() {
	let lastTimestamp = 0;
	let acceleration = 0;
	window.addEventListener('wheel', (e) => {
		e.preventDefault();
		const now = performance.now();
		let deltaTime = Math.min(32, now - lastTimestamp);
		if (deltaTime < 1) deltaTime = 16;
		lastTimestamp = now;
		const rawDelta = e.deltaY * inertiaParams.scrollSensitivity * inertiaParams.strength;
		let maxAccel = 0.015;
		let deltaAccel = rawDelta - acceleration;
		deltaAccel = Math.max(-maxAccel, Math.min(maxAccel, deltaAccel));
		acceleration += deltaAccel;
		acceleration = Math.max(-0.03, Math.min(0.03, acceleration));
		let targetDelta = acceleration;
		targetVelocity = targetVelocity * inertiaParams.directionSmoothing + targetDelta * (1 - inertiaParams.directionSmoothing);
		targetVelocity = Math.max(-inertiaParams.maxSpeed, Math.min(inertiaParams.maxSpeed, targetVelocity));
	}, {
		passive: false
	});

	function updateInertia() {
		targetVelocity *= inertiaParams.friction;
		currentVelocity = currentVelocity * 0.85 + targetVelocity * 0.15;
		if (Math.abs(currentVelocity) > 0.0001) {
			scrollOffset += currentVelocity;
			updateUVOffset();
		} else {
			currentVelocity = 0;
			targetVelocity = 0;
			acceleration = 0;
		}
		updateTouchInertia();
	}
	window._updateInertia = updateInertia;
}

function setupArrowKeysZoom() {
	let zoomLevel = 1.0;
	const minZoom = 0.84;
	const maxZoom = 1;
	window.addEventListener('keydown', (e) => {
		if (e.key === 'ArrowRight') {
			e.preventDefault();
			zoomLevel += 0.05;
			if (zoomLevel > maxZoom) zoomLevel = maxZoom;
			camera.position.z = (window.innerWidth < 600 ? 11 : 9) / zoomLevel;
		} else if (e.key === 'ArrowLeft') {
			e.preventDefault();
			zoomLevel -= 0.05;
			if (zoomLevel < minZoom) zoomLevel = minZoom;
			camera.position.z = (window.innerWidth < 600 ? 11 : 9) / zoomLevel;
		}
	});
}

function setupDrag() {
	const container = document.getElementById('kumonosu-webgl-container');
	container.style.pointerEvents = 'auto';
	container.style.cursor = 'grab';
	container.addEventListener('mousedown', (e) => {
		isDragging = true;
		previousMousePosition = {
			x: e.clientX,
			y: e.clientY
		};
		container.style.cursor = 'grabbing';
		e.preventDefault();
	});
	window.addEventListener('mousemove', (e) => {
		if (!isDragging) return;
		const dx = e.clientX - previousMousePosition.x;
		const dy = e.clientY - previousMousePosition.y;
		dragRotation.z += dx * 0.002;
		dragRotation.x -= dy * 0.002;
		dragRotation.x = Math.max(-0.35, Math.min(0.35, dragRotation.x));
		dragRotation.z = Math.max(-0.35, Math.min(0.35, dragRotation.z));
		tiltGroup.rotation.x = baseRotation.x + dragRotation.x;
		tiltGroup.rotation.z = baseRotation.z + dragRotation.z;
		previousMousePosition = {
			x: e.clientX,
			y: e.clientY
		};
	});
	window.addEventListener('mouseup', () => {
		isDragging = false;
		container.style.cursor = 'grab';
	});
}

function onResize() {
	const width = window.innerWidth;
	const height = window.innerHeight;
	camera.aspect = width / height;
	camera.updateProjectionMatrix();
	renderer.setSize(width, height);
	if (width < 600) {
		config.spiralRadius = 1.8;
		config.imageHeight = 4.0;
		config.spiralHeight = 10 + (numberOfImages - 21) * 0.25;
		config.centerY = 3.5;
		camera.position.set(0, 2.5, 11);
	} else if (width < 1000) {
		config.spiralRadius = 2.6;
		config.imageHeight = 5.5;
		config.spiralHeight = 11 + (numberOfImages - 21) * 0.25;
		config.centerY = 4.0;
		camera.position.set(0, 3.0, 10);
	} else {
		config.spiralRadius = 3.5;
		config.imageHeight = 7;
		config.spiralHeight = 12 + (numberOfImages - 21) * 0.25;
		config.centerY = 4.38;
		camera.position.set(0, 3.5, 9);
	}
	rebuildGeometry();
}

function animate() {
	requestAnimationFrame(animate);
	if (window._updateInertia) {
		window._updateInertia();
	}
	renderer.render(scene, camera);
}
init();
https://cdnjs.cloudflare.com/ajax/libs/three.js/0.160.0/three.min.js

Explanation 詳しい説明

仕様

このギャラリーは、Three.jsのWebGLレンダリングをベースに、複数の画像を1本のスパイラル(らせん)上に配置します。全画像を1枚のマスターテクスチャ(Canvas)に結合し、カスタムシェーダー(GLSL)でUVオフセットをずらすことで、スクロールに連動した無限ループ回転を実現しています。

主な機能は以下のとおりです。

  • らせん配置: 画像を3D空間上のスパイラルに沿って配置。画像枚数に応じて巻き数・高さが自動調整されます。
  • 慣性スクロール: マウスホイールやタッチスワイプ操作に慣性がかかり、なめらかに減速します。加速度制限・速度制限付きで暴走を防止します。
  • ドラッグ傾斜: マウスドラッグ(PCの場合)や2本指スワイプ(スマホの場合)で、スパイラル全体の傾き(X軸・Z軸)を変更できます。傾き量には上限があります。
  • キーボードズーム: 矢印キー(左右)でカメラのズームイン・ズームアウトが可能です。
  • レスポンシブ対応: 画面幅に応じて、スパイラルの半径・画像サイズ・カメラ位置が3段階(600px未満 / 600〜999px / 1000px以上)で切り替わります。
  • カスタムシェーダー: 頂点シェーダーとフラグメントシェーダーにより、UVスクロールのループ処理をGPU側で高速に行います。

カスタマイズ

  • 画像の変更: imageUrls配列のURLを差し替えるだけで、表示画像を自由に変更できます。枚数の増減にも自動対応します。
  • スパイラル形状: configオブジェクト内のspiralRadius(半径)、spiralTurns(巻き数)、spiralHeight(高さ)を調整することで、らせんの密度や広がり方を変更できます。
  • 画像サイズ: config.imageHeightで画像の高さ(≒表示サイズ)を変更できます。
  • 曲面の深さ: config.curvatureの値を変えると、各画像パネルの曲がり具合(内側への反り)が変化します。
  • スクロール感度・慣性: inertiaParams内のfriction(摩擦)、strength(強さ)、maxSpeed(最高速度)、scrollSensitivity(感度)を調整することで、スクロールの手触りを細かくチューニングできます。
  • カメラ位置・傾き: camera.position.set()baseRotationの値を変更すれば、初期視点や傾きの初期値を変えられます。
  • 背景色: scene.backgroundおよびCSSのbody背景色を変更することで、全体の雰囲気を変えられます。

注意点

  • Three.jsの読み込み: CDN(cdnjs.cloudflare.com)からThree.js r160を読み込んでいます。本番環境ではバージョン固定やローカルホスティングを推奨します。
  • 画像のCORS: 外部画像をCanvasに描画するため、crossOrigin = 'Anonymous'を設定しています。CORS非対応のサーバーから画像を読み込む場合、テクスチャが正しく生成されません。自サーバーに画像を置くか、CORS対応のCDNを使用してください。
  • パフォーマンス: 画像枚数が多いほどマスターテクスチャが大きくなり、GPUメモリを消費します。モバイル端末では20〜30枚程度を目安にしてください。
  • 画像の読み込みエラー: 読み込みに失敗した画像はアスペクト比0.8のダミーとして処理されますが、該当箇所は黒く表示されます。
  • スクロール競合: wheelイベントにpreventDefault()を設定しているため、このギャラリーが画面全体を覆う前提の設計です。ページ内の一部セクションとして使う場合は、イベント制御の見直しが必要です。
  • アクセシビリティ: WebGLベースのため、スクリーンリーダーや支援技術からは画像の内容を認識できません。別途テキストによる代替情報の提供を検討してください。