Three.js表現・UI演出まとめ|WebGLで作る3Dアニメーション実装集

特集

Three.js表現・UI演出まとめ|WebGLで作る3Dアニメーション実装集

投稿日2026/06/01

更新日2026/5/15

Three.jsは、Webサイトに高度な3D表現やインタラクションを実装できる人気のJavaScriptライブラリです。近年では、単なる演出にとどまらず、UIやブランド体験の一部としてThree.jsを活用するサイトも増えています。

本ページでは、Three.jsやGLSLを使ったさまざまな表現アイデアや実装記事をまとめました。画像ギャラリーやスクロール演出、3Dテキスト、パララックスアニメーションなど、実践的でクリエイティブな作例を幅広く紹介しています。

「印象に残るWebサイトを作りたい」「最新の3D表現を取り入れたい」という方は、ぜひ制作の参考にしてみてください。

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

<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
Three.jsで画像をらせん状に並べ、スクロールで回転する3Dギャラリー。

慣性スクロール・ドラッグ傾斜・タッチ対応・レスポンシブ対応を備えた、没入感のあるWebGL演出です。

Three.jsとGLSLで作る、母の日に贈るクリックで花が咲くアニメーション

<div class="kumonosu-container">
    <canvas id="kumonosu-canvas"></canvas>
    <div class="kumonosu-name">
        お母さんいつもありがとう
        <span>Tap the screen to bloom</span>
    </div>
    <div class="kumonosu-clean-btn">clear</div>
</div>
html, body {
	overflow: hidden;
	position: fixed;
	width: 100%;
	height: 100%;
	margin: 0;
	padding: 0;
	background-color: #000;
	touch-action: none !important;
	-webkit-user-select: none;
	user-select: none;
}
.kumonosu-container {
	position: absolute;
	top: 0;
	left: 0;
	width: 100%;
	height: 100%;
	background: #000;
	touch-action: none;
	overflow: hidden;
}
#kumonosu-canvas {
	display: block;
	width: 100% !important;
	height: 100% !important;
	touch-action: none;
	-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
	/* PCのカーソルを矢印に変更 */
	cursor: default;
}
.kumonosu-name {
	position: absolute;
	top: 50%;
	left: 50%;
	width: 90%;
	/* 横幅いっぱいに広がらないよう調整 */
	transform: translate(-50%, -50%);
	color: white;
	text-align: center;
	font-size: 24px;
	/* スマホ向け標準サイズ */
	text-shadow: 0 0 15px #000;
	pointer-events: none;
	z-index: 10;
	font-family: sans-serif;
	font-weight: bold;
	line-height: 1.4;
}
.kumonosu-name span {
	display: block;
	font-size: 14px;
	margin-top: 10px;
	opacity: 0.8;
	font-weight: normal;
}
/* 被らないよう右下に配置を変更 */
.kumonosu-clean-btn {
	position: absolute;
	bottom: 30px;
	right: 25px;
	/* 左から右に変更 */
	z-index: 20;
	font-family: sans-serif;
	font-size: 13px;
	color: white;
	text-decoration: underline;
	padding: 10px;
	opacity: .4;
	/* 少し控えめに */
	cursor: pointer;
	-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
/* PCやタブレットでの調整 */
@media all and (min-width: 640px) {
	.kumonosu-name {
		font-size: 45px;
	}
	.kumonosu-name span {
		font-size: 18px;
	}
	.kumonosu-clean-btn {
		font-size: 15px;
		bottom: 20px;
		right: 20px;
	}
}
import * as THREE from "https://esm.sh/three@0.133.1/build/three.module";
const vertexShaderCode = `
        varying vec2 vUv;
        void main() {
            vUv = uv;
            gl_Position = vec4(position, 1.0);
        }
    `;
const fragmentShaderCode = `
        precision highp float;
        #define PI 3.14159265359
        uniform float u_ratio;
        uniform vec2 u_cursor;
        uniform float u_stop_time;
        uniform float u_clean;
        uniform vec2 u_stop_randomizer;
        uniform sampler2D u_texture;
        varying vec2 vUv;

        vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
        vec2 mod289(vec2 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
        vec3 permute(vec3 x) { return mod289(((x*34.0)+1.0)*x); }
        float snoise(vec2 v) {
            const vec4 C = vec4(0.211324865405187, 0.366025403784439, -0.577350269189626, 0.024390243902439);
            vec2 i = floor(v + dot(v, C.yy));
            vec2 x0 = v - i + dot(i, C.xx);
            vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
            vec4 x12 = x0.xyxy + C.xxzz;
            x12.xy -= i1;
            i = mod289(i);
            vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0));
            vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), 0.0);
            m = m*m*m*m;
            vec3 x = 2.0 * fract(p * C.www) - 1.0;
            vec3 h = abs(x) - 0.5;
            vec3 ox = floor(x + 0.5);
            vec3 a0 = x - ox;
            m *= 1.79284291400159 - 0.85373472095314 * (a0*a0 + h*h);
            vec3 g;
            g.x = a0.x * x0.x + h.x * x0.y;
            g.yz = a0.yz * x12.xz + h.yz * x12.yw;
            return 130.0 * dot(m, g);
        }

        float get_flower_shape(vec2 _p, float _pet_n, float _angle, float _outline) {
            _angle *= 3.;
            _p = vec2(_p.x * cos(_angle) - _p.y * sin(_angle), _p.x * sin(_angle) + _p.y * cos(_angle));
            float a = atan(_p.y, _p.x);
            float flower_sectoral_shape = pow(abs(sin(a * _pet_n)), .4) + .25;
            vec2 flower_size_range = vec2(.03, .1);
            float size = flower_size_range[0] + u_stop_randomizer[0] * flower_size_range[1];
            float flower_radial_shape = pow(length(_p) / size, 2.);
            flower_radial_shape -= .1 * sin(8. * a);
            flower_radial_shape = max(.1, flower_radial_shape);
            flower_radial_shape += smoothstep(0., 0.03, -_p.y + .2 * abs(_p.x));
            float grow_time = step(.25, u_stop_time) * pow(u_stop_time, .3);
            return (1. - smoothstep(0., flower_sectoral_shape, _outline * flower_radial_shape / grow_time)) * (1. - step(1., grow_time));
        }

        float get_stem_shape(vec2 _p, vec2 _uv, float _w, float _angle) {
            _w = max(.004, _w);
            float x_offset = _p.y * sin(_angle) * pow(3. * _uv.y, 2.);
            _p.x -= x_offset;
            float cursor_horizontal_noise = .5 * snoise(2. * _uv * u_stop_randomizer[0]) * pow(dot(_p.y, _p.y), .6) * pow(dot(_uv.y, _uv.y), .3);
            _p.x += cursor_horizontal_noise;
            float stem_shape = smoothstep(-_w, 0., _p.x) * (1. - smoothstep(0., _w, _p.x));
            return stem_shape * smoothstep(0., pow(1. - smoothstep(0., .2, u_stop_time), .5), .03 -_p.y) * (1. - step(.17, u_stop_time));
        }

        void main() {
            vec3 base = texture2D(u_texture, vUv).xyz;
            vec2 uv = vUv; uv.x *= u_ratio;
            vec2 cursor = vUv - u_cursor.xy; cursor.x *= u_ratio;
            vec3 stem_color = vec3(.1 + u_stop_randomizer[0] * .6, .6, .2);
            vec3 flower_color = vec3(.6 + .5 * u_stop_randomizer[1], .1, .9 - .5 * u_stop_randomizer[1]);
            float angle = .5 * (u_stop_randomizer[0] - .5);
            float stem_shape = get_stem_shape(cursor, uv, .003, angle) + get_stem_shape(cursor + vec2(0., .2 + .5 * u_stop_randomizer[0]), uv, .003, angle);
            float stem_mask = 1. - get_stem_shape(cursor, uv, .004, angle) - get_stem_shape(cursor + vec2(0., .2 + .5 * u_stop_randomizer[0]), uv, .004, angle);
            float angle_offset = -(2. * step(0., angle) - 1.) * .1 * u_stop_time;
            float flower_back_shape = get_flower_shape(cursor, 1. + floor(u_stop_randomizer[0] * 2.), angle + angle_offset, 1.5);
            float flower_back_mask = 1. - get_flower_shape(cursor, 1. + floor(u_stop_randomizer[0] * 2.), angle + angle_offset, 1.6);
            float flower_front_shape = get_flower_shape(cursor, 2. + floor(u_stop_randomizer[1] * 2.), angle, 1.);
            float flower_front_mask = 1. - get_flower_shape(cursor, 2. + floor(u_stop_randomizer[1] * 2.), angle, .95);
            vec3 color = (base * stem_mask * flower_back_mask * flower_front_mask) + (stem_shape * stem_color) + (flower_back_shape * (flower_color + vec3(0., .8 * u_stop_time, 0.))) + (flower_front_shape * flower_color);
            color.r *= 1. - (.5 * flower_back_shape * flower_front_shape);
            color.b *= 1. - (flower_back_shape * flower_front_shape);
            gl_FragColor = vec4(color * u_clean, 1.);
        }
    `;
const canvasEl = document.querySelector("#kumonosu-canvas");
const cleanBtn = document.querySelector(".kumonosu-clean-btn");
const pointer = {
	x: 0,
	y: 0,
	clicked: false,
	vanishCanvas: false
};
let renderer, sceneShader, sceneBasic, camera, clock, shaderMaterial, basicMaterial;
let renderTargets = [null, null];

function init() {
	renderer = new THREE.WebGLRenderer({
		canvas: canvasEl,
		alpha: true,
		preserveDrawingBuffer: true
	});
	renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
	sceneShader = new THREE.Scene();
	sceneBasic = new THREE.Scene();
	camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 10);
	clock = new THREE.Clock();
	const dummyTex = new THREE.DataTexture(new Uint8Array([0, 0, 0, 0]), 1, 1, THREE.RGBAFormat);
	shaderMaterial = new THREE.ShaderMaterial({
		uniforms: {
			u_stop_time: {
				value: 1.0
			},
			u_stop_randomizer: {
				value: new THREE.Vector2(Math.random(), Math.random())
			},
			u_cursor: {
				value: new THREE.Vector2(0.5, 0.5)
			},
			u_ratio: {
				value: window.innerWidth / window.innerHeight
			},
			u_texture: {
				value: dummyTex
			},
			u_clean: {
				value: 1.0
			},
		},
		vertexShader: vertexShaderCode,
		fragmentShader: fragmentShaderCode
	});
	basicMaterial = new THREE.MeshBasicMaterial();
	sceneShader.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), shaderMaterial));
	sceneBasic.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), basicMaterial));
	updateSize();
	const startInteraction = (e) => {
		if (e.target.closest('.kumonosu-clean-btn')) return;
		const touch = e.touches ? e.touches[0] : e;
		const rect = canvasEl.getBoundingClientRect();
		pointer.x = (touch.clientX - rect.left) / rect.width;
		pointer.y = (touch.clientY - rect.top) / rect.height;
		pointer.clicked = true;
		if (e.cancelable) e.preventDefault();
	};
	canvasEl.addEventListener("touchstart", startInteraction, {
		passive: false
	});
	canvasEl.addEventListener("mousedown", startInteraction);
	const clearCanvas = (e) => {
		e.preventDefault();
		e.stopPropagation();
		pointer.vanishCanvas = true;
		setTimeout(() => {
			pointer.vanishCanvas = false;
		}, 50);
	};
	cleanBtn.addEventListener("touchstart", clearCanvas, {
		passive: false
	});
	cleanBtn.addEventListener("mousedown", clearCanvas);
	window.addEventListener("resize", updateSize);
	render();
}

function updateSize() {
	const w = window.innerWidth || document.documentElement.clientWidth;
	const h = window.innerHeight || document.documentElement.clientHeight;
	renderer.setSize(w, h);
	renderTargets[0] = new THREE.WebGLRenderTarget(w, h);
	renderTargets[1] = new THREE.WebGLRenderTarget(w, h);
	shaderMaterial.uniforms.u_ratio.value = w / h;
}

function render() {
	requestAnimationFrame(render);
	shaderMaterial.uniforms.u_clean.value = pointer.vanishCanvas ? 0 : 1;
	shaderMaterial.uniforms.u_texture.value = renderTargets[0].texture;
	if (pointer.clicked) {
		shaderMaterial.uniforms.u_cursor.value = new THREE.Vector2(pointer.x, 1 - pointer.y);
		shaderMaterial.uniforms.u_stop_randomizer.value = new THREE.Vector2(Math.random(), Math.random());
		shaderMaterial.uniforms.u_stop_time.value = 0.;
		pointer.clicked = false;
	}
	shaderMaterial.uniforms.u_stop_time.value += clock.getDelta();
	renderer.setRenderTarget(renderTargets[1]);
	renderer.render(sceneShader, camera);
	basicMaterial.map = renderTargets[1].texture;
	renderer.setRenderTarget(null);
	renderer.render(sceneBasic, camera);
	let tmp = renderTargets[0];
	renderTargets[0] = renderTargets[1];
	renderTargets[1] = tmp;
}
init();
クリックした場所に花が咲くWebGLインタラクション。

GLSLシェーダーで花びら・茎をプロシージャル生成し、色や形もランダムに変化。画像不要で軽量なジェネラティブ演出です。

Three.jsとGLSLで作る、画像が立体的に動く3Dレリーフエフェクト

<div id="kumonosu-viewport">
	<label for="kumonosu-photoUpload" class="kumonosu-upload-btn">UPLOAD</label>
	<input type="file" id="kumonosu-photoUpload" accept="image/jpeg, image/png, image/jpg">
</div>
#kumonosu-viewport {
	width: 100vw;
	height: 100vh;
	overflow: hidden;
	background: #050510;
	position: relative;
	display: flex;
	align-items: center;
	justify-content: center;
}
.kumonosu-upload-btn {
	position: absolute;
	top: 20px;
	right: 20px;
	background: rgba(0, 0, 0, 0.9);
	backdrop-filter: blur(8px);
	padding: 8px 14px;
	border-radius: 30px;
	color: tomato;
	font-size: 0.7rem;
	font-family: monospace;
	cursor: pointer;
	z-index: 30;
	transition: all 0.2s ease;
	border: 1px solid rgba(255, 99, 71, 0.3);
	pointer-events: auto;
}
.kumonosu-upload-btn:hover {
	background: rgba(40, 20, 10, 0.7);
	border-color: tomato;
}
#kumonosu-photoUpload {
	display: none;
}
// esm.sh を使うことで、依存関係のエラー(Failed to resolve)を自動で解決します
import * as THREE from "https://esm.sh/three@0.128.0";
import {
	OrbitControls
} from "https://esm.sh/three@0.128.0/examples/jsm/controls/OrbitControls.js";
const container = document.getElementById('kumonosu-viewport');
const refHeight = 1.2;
let reliefMaterial = null;
let imagePlane = null;
const mouseLightDir = new THREE.Vector2(0.5, 0.5);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x080818);
const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 0, 3.0);
const renderer = new THREE.WebGLRenderer({
	antialias: true
});
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
container.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.enablePan = false;
const vertexShader = `
        varying vec2 vUv;
        void main() {
            vUv = uv;
            gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
        }
    `;
const fragmentShader = `
        uniform sampler2D uTexture;
        uniform vec2 uLightDir;
        varying vec2 vUv;
        float getLum(vec3 c) { return dot(c, vec3(0.299, 0.587, 0.114)); }
        void main() {
            vec2 uv = vUv;
            vec4 tex = texture2D(uTexture, uv);
            float o = 0.007;
            float lumR = getLum(texture2D(uTexture, uv + vec2(o, 0.0)).rgb);
            float lumL = getLum(texture2D(uTexture, uv - vec2(o, 0.0)).rgb);
            float lumU = getLum(texture2D(uTexture, uv + vec2(0.0, o)).rgb);
            float lumD = getLum(texture2D(uTexture, uv - vec2(0.0, o)).rgb);
            vec3 n = normalize(vec3(lumR - lumL, lumU - lumD, 1.0));
            vec3 l = normalize(vec3(uLightDir.x, uLightDir.y, 1.0));
            float d = max(0.2, dot(n, l));
            vec3 col = ((tex.rgb - 0.5) * 1.15 + 0.5) * (d + 0.3);
            if (!gl_FrontFacing) col = mix(col, vec3(1.0), 0.6);
            gl_FragColor = vec4(col * (1.0 - length(uv - 0.5) * 0.25), 1.0);
        }
    `;

function fitCamera() {
	if (!imagePlane) return;
	const {
		width,
		height
	} = imagePlane.geometry.parameters;
	const dist = (window.innerWidth / window.innerHeight >= width / height) ? (height / 2) / Math.tan((camera.fov * Math.PI / 180) / 2) : (width / (window.innerWidth / window.innerHeight) / 2) / Math.tan((camera.fov * Math.PI / 180) / 2);
	camera.position.z = dist * 1.5;
	controls.update();
}

function updatePlane(img) {
	const canvas = document.createElement('canvas');
	canvas.width = img.width;
	canvas.height = img.height;
	canvas.getContext('2d').drawImage(img, 0, 0);
	const tex = new THREE.CanvasTexture(canvas);
	const mat = new THREE.ShaderMaterial({
		uniforms: {
			uTexture: {
				value: tex
			},
			uLightDir: {
				value: mouseLightDir
			}
		},
		vertexShader,
		fragmentShader,
		side: THREE.DoubleSide
	});
	if (imagePlane) {
		scene.remove(imagePlane);
		imagePlane.geometry.dispose();
		imagePlane.material.dispose();
	}
	imagePlane = new THREE.Mesh(new THREE.PlaneGeometry(refHeight * (img.width / img.height), refHeight, 64, 64), mat);
	scene.add(imagePlane);
	reliefMaterial = mat;
	fitCamera();
}
window.addEventListener('mousemove', (e) => {
	mouseLightDir.set((e.clientX / window.innerWidth) * 2 - 1, -(e.clientY / window.innerHeight) * 2 + 1).multiplyScalar(1.2);
});
window.addEventListener('resize', () => {
	camera.aspect = window.innerWidth / window.innerHeight;
	camera.updateProjectionMatrix();
	renderer.setSize(window.innerWidth, window.innerHeight);
	fitCamera();
});
document.getElementById('kumonosu-photoUpload').addEventListener('change', (e) => {
	const file = e.target.files[0];
	if (!file) return;
	const reader = new FileReader();
	reader.onload = (ev) => {
		const img = new Image();
		img.onload = () => updatePlane(img);
		img.src = ev.target.result;
	};
	reader.readAsDataURL(file);
});
const defImg = new Image();
defImg.crossOrigin = "Anonymous";
defImg.onload = () => updatePlane(defImg);
// file:/// で実行する場合、外部URLの画像がセキュリティで弾かれることがあります
defImg.src = 'https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?w=800&q=80';

function animate() {
	controls.update();
	renderer.render(scene, camera);
	requestAnimationFrame(animate);
}
animate();
画像の明暗から法線を生成し、マウス連動の光源でリアルタイムに陰影を描画する3Dレリーフエフェクト。

Three.js+GLSLで実装し、画像アップロードや回転・ズーム操作にも対応しています。

Three.jsで作る、スクロールで画像がタイル状に回転する3Dセクション演出

<main class="kumonosu-main">
	<div class="kumonosu-text-container">
		<section class="kumonosu-section">
			<h1 class="kumonosu-title">セクション 01</h1>
			<p class="kumonosu-description"> ここに仮のテキストが入ります。スクロールに合わせて右側の画像がタイル状に回転し、次のシーンへ切り替わります。 どんなデバイスでも画像が画面いっぱいに広がるよう設計されています。 </p>
		</section>
		<section class="kumonosu-section">
			<h1 class="kumonosu-title">セクション 02</h1>
			<p class="kumonosu-description"> スクロールスナップ機能により、スクロールを止めると自動的に各セクションが中央に吸い寄せられます。 スマートフォンでは画像が上、テキストが下のレイアウトに自動的に切り替わります。 </p>
		</section>
		<section class="kumonosu-section">
			<h1 class="kumonosu-title">セクション 03</h1>
			<p class="kumonosu-description"> ブラウザの幅や高さを変えても、画像に黒い隙間ができることはありません。 Three.jsの数学的な計算により、常に最適なトリミング位置を維持します。 </p>
		</section>
		<section class="kumonosu-section">
			<h1 class="kumonosu-title">セクション 04</h1>
			<p class="kumonosu-description"> 最後の画像です。全4枚の画像がシームレスに入れ替わります。 このファイルは外部ファイルを必要とせず、単体で動作するようにまとめられています。 </p>
		</section>
	</div>
	<canvas id="kumonosu-canvas"></canvas>
</main>
body {
	margin: 0;
	padding: 0;
	background-color: #fff;
	color: #333;
	font-family: "Helvetica Neue", Arial, "Hiragino Kaku Gothic ProN", "Hiragino Sans", Meiryo, sans-serif;
	overflow: hidden;
}
.kumonosu-main {
	display: flex;
	flex-direction: row;
	width: 100vw;
	height: 100vh;
}
.kumonosu-text-container {
	width: 50%;
	height: 100vh;
	box-sizing: border-box;
	padding: 0 3rem;
	display: flex;
	flex-direction: column;
	overflow-y: scroll;
	scroll-snap-type: y mandatory;
	z-index: 10;
	background: white;
}
#kumonosu-canvas {
	width: 50%;
	height: 100vh;
	display: block;
	background-color: #000;
}
.kumonosu-section {
	height: 100vh;
	width: 100%;
	box-sizing: border-box;
	display: flex;
	flex-shrink: 0;
	flex-direction: column;
	justify-content: center;
	scroll-snap-stop: always;
	scroll-snap-align: center;
	border-bottom: 1px solid #eee;
}
.kumonosu-title {
	font-size: 2.5em;
	margin: 0 0 1rem 0;
	font-weight: 700;
	line-height: 1.2;
}
.kumonosu-description {
	font-size: 1.1em;
	line-height: 1.8;
	color: #666;
}
@media (max-width: 800px) {
	.kumonosu-main {
		flex-direction: column-reverse;
	}
	.kumonosu-text-container {
		width: 100%;
		height: 50vh;
		padding: 0 1.5rem;
	}
	#kumonosu-canvas {
		width: 100% !important;
		height: 50vh !important;
	}
	.kumonosu-section {
		height: 50vh;
	}
	.kumonosu-title {
		font-size: 1.6em;
	}
}
import * as THREE from "https://unpkg.com/three@0.182.0/build/three.module.js";
const scene = new THREE.Scene();
const canvEl = document.getElementById("kumonosu-canvas");
const renderer = new THREE.WebGLRenderer({
	canvas: canvEl,
	antialias: true
});
const camera = new THREE.PerspectiveCamera(100, 1, 0.1, 1000);
camera.position.z = 100;
const imgUrls = ["https://images.unsplash.com/photo-1506744038136-46273834b3fb?w=1200&auto=format&fit=crop&q=80", "https://images.unsplash.com/photo-1470770841072-f978cf4d019e?w=1200&auto=format&fit=crop&q=80", "https://images.unsplash.com/photo-1441974231531-c6227db76b6e?w=1200&auto=format&fit=crop&q=80", "https://images.unsplash.com/photo-1501785888041-af3ef285b470?w=1200&auto=format&fit=crop&q=80", ];
const applyCover = (texture, aspect) => {
	const imageAspect = texture.image.width / texture.image.height;
	if (imageAspect > aspect) {
		texture.repeat.set(aspect / imageAspect, 1);
		texture.offset.set((1 - texture.repeat.x) / 2, 0);
	} else {
		texture.repeat.set(1, imageAspect / aspect);
		texture.offset.set(0, (1 - texture.repeat.y) / 2);
	}
};
const loader = new THREE.TextureLoader();
const textures = await Promise.all(imgUrls.map(async (url) => {
	const tex = await loader.loadAsync(url);
	tex.colorSpace = THREE.SRGBColorSpace;
	return tex;
}));
const materials = [
	new THREE.MeshBasicMaterial({
		map: textures[1]
	}), // 右
	new THREE.MeshBasicMaterial({
		map: textures[3]
	}), // 左
	new THREE.MeshBasicMaterial({
		color: 0xffffff
	}), // 上
	new THREE.MeshBasicMaterial({
		color: 0xffffff
	}), // 下
	new THREE.MeshBasicMaterial({
		map: textures[0]
	}), // 前
	new THREE.MeshBasicMaterial({
		map: textures[2]
	}), // 後
];
const BOXES_X = 10;
const BOXES_Y = 10;
const boxes = [];
const group = new THREE.Group();
scene.add(group);
for (let j = 0; j < BOXES_Y; j++) {
	for (let i = 0; i < BOXES_X; i++) {
		const geo = new THREE.BoxGeometry(1, 1, 1);
		const uvs = geo.attributes.uv.array;
		const u0 = i / BOXES_X;
		const v0 = 1 - (j + 1) / BOXES_Y;
		const u1 = (i + 1) / BOXES_X;
		const v1 = 1 - j / BOXES_Y;
		for (let k = 0; k < uvs.length; k += 8) {
			uvs[k] = u0;
			uvs[k + 1] = v1;
			uvs[k + 2] = u1;
			uvs[k + 3] = v1;
			uvs[k + 4] = u0;
			uvs[k + 5] = v0;
			uvs[k + 6] = u1;
			uvs[k + 7] = v0;
		}
		const mesh = new THREE.Mesh(geo, materials);
		const pivot = new THREE.Group();
		pivot.add(mesh);
		group.add(pivot);
		boxes.push(pivot);
	}
}
const updateLayout = () => {
	const w = canvEl.clientWidth;
	const h = canvEl.clientHeight;
	renderer.setSize(w, h, false);
	camera.aspect = w / h;
	camera.updateProjectionMatrix();
	const fov = THREE.MathUtils.degToRad(camera.fov);
	const visibleHeight = 2 * Math.tan(fov / 2) * 100;
	const visibleWidth = visibleHeight * camera.aspect;
	const cellW = visibleWidth / BOXES_X;
	const cellH = visibleHeight / BOXES_Y;
	const boxDepth = Math.max(cellW, cellH);
	textures.forEach(tex => applyCover(tex, camera.aspect));
	boxes.forEach((pivot, index) => {
		const i = index % BOXES_X;
		const j = Math.floor(index / BOXES_X);
		pivot.position.set(-visibleWidth / 2 + cellW / 2 + i * cellW, visibleHeight / 2 - cellH / 2 - j * cellH, 0);
		pivot.children[0].scale.set(cellW, cellH, boxDepth);
	});
};
window.addEventListener('resize', updateLayout);
updateLayout();
const scrollContainer = document.querySelector(".kumonosu-text-container");
scrollContainer.addEventListener("scroll", () => {
	const scrollPercent = scrollContainer.scrollTop / (scrollContainer.scrollHeight - scrollContainer.clientHeight);
	const rotation = scrollPercent * Math.PI * 1.5;
	boxes.forEach(box => box.rotation.y = rotation);
});
renderer.setAnimationLoop(() => renderer.render(scene, camera));
画像を10×10のタイルに分割し、スクロール連動で3D回転させて画像を切り替えるセクション演出。

Three.jsのBoxGeometry各面に異なる画像を配置し、スクロールスナップ・レスポンシブにも対応しています。

Three.jsで作る、入力テキストが3Dメタル文字に変わるジェネレーター

<div class="kumonosu-ui-container">
	<input type="text" id="kumonosu-morphInput" class="kumonosu-text-input" placeholder="Type here..." maxlength="20" value="HAPPY">
	<button id="kumonosu-morphBtn" class="kumonosu-morph-button">Morph</button>
	<div class="kumonosu-disclaimer">※日本語には対応していません</div>
</div>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap');
body {
	margin: 0;
	overflow: hidden;
	background-color: #050505;
	font-family: 'Inter', sans-serif;
}
.kumonosu-ui-container {
	position: fixed;
	bottom: 40px;
	left: 50%;
	transform: translateX(-50%);
	display: flex;
	gap: 10px;
	z-index: 100;
	padding: 10px;
	border-radius: 14px;
	backdrop-filter: blur(10px);
	width: auto;
	max-width: 90vw;
	box-sizing: border-box;
}
.kumonosu-text-input {
	width: 300px;
	background: #181818;
	border: 1px solid #333;
	border-radius: 8px;
	color: #fff;
	padding: 12px 16px;
	font-size: 16px;
	outline: none;
	transition: border-color 0.2s;
	flex-shrink: 1;
	min-width: 0;
}
.kumonosu-text-input:focus {
	border-color: #555;
}
.kumonosu-text-input::placeholder {
	color: #555;
}
.kumonosu-disclaimer {
	position: absolute;
	bottom: -22px;
	left: 14px;
	font-size: 10px;
	color: #666;
	letter-spacing: 0.02em;
	pointer-events: none;
	white-space: nowrap;
}
.kumonosu-morph-button {
	background: #6366f1;
	color: white;
	border: none;
	border-radius: 8px;
	padding: 0 20px;
	font-size: 15px;
	font-weight: 700;
	cursor: pointer;
	transition: transform 0.1s, background 0.2s;
	display: flex;
	align-items: center;
	justify-content: center;
	flex-shrink: 0;
}
.kumonosu-morph-button:hover {
	background: #4f46e5;
}
.kumonosu-morph-button:active {
	transform: scale(0.95);
}
@media (max-width: 600px) {
	.kumonosu-ui-container {
		bottom: 30px;
		width: 92vw;
		padding: 8px;
	}
	.kumonosu-text-input {
		width: 100%;
		padding: 10px 12px;
		font-size: 14px;
	}
	.kumonosu-morph-button {
		padding: 0 15px;
		font-size: 14px;
	}
	.kumonosu-disclaimer {
		bottom: -18px;
		font-size: 9px;
	}
}
import * as THREE from 'https://esm.sh/three@0.150.0';
import {
	OrbitControls
} from 'https://esm.sh/three@0.150.0/examples/jsm/controls/OrbitControls.js';
import {
	TextGeometry
} from 'https://esm.sh/three@0.150.0/examples/jsm/geometries/TextGeometry.js';
import {
	FontLoader
} from 'https://esm.sh/three@0.150.0/examples/jsm/loaders/FontLoader.js';
// --- シーン設定 ---
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0a0a1a);
const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000);

function adjustCamera() {
	camera.aspect = window.innerWidth / window.innerHeight;
	if (window.innerWidth < 600) {
		camera.position.set(0, 0.8, 7.5);
	} else {
		camera.position.set(-1.8, 0.8, 4.5);
	}
	camera.updateProjectionMatrix();
}
adjustCamera();
const renderer = new THREE.WebGLRenderer({
	antialias: true
});
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
// --- ライティング ---
const ambientLight = new THREE.AmbientLight(0x404050, 0.6);
const mainLight = new THREE.DirectionalLight(0xffffff, 1.5);
mainLight.position.set(5, 5, 5);
mainLight.castShadow = true;
const backLight = new THREE.PointLight(0xffaa88, 1.2);
backLight.position.set(-3, 2, -4);
const rimLight = new THREE.PointLight(0xffffff, 0.8);
rimLight.position.set(0, 4, -2);
const frontLight = new THREE.PointLight(0xaaccff, 0.5);
frontLight.position.set(0, 0, 4);
const bottomLight = new THREE.PointLight(0x443322, 0.4);
bottomLight.position.set(0, -3, 0);
[ambientLight, mainLight, backLight, rimLight, frontLight, bottomLight].forEach(l => scene.add(l));
const FONT_URL = 'https://threejs.org/examples/fonts/helvetiker_bold.typeface.json';
// --- メタルテクスチャ ---
function createMetalMap() {
	const canvas = document.createElement('canvas');
	canvas.width = 512;
	canvas.height = 512;
	const ctx = canvas.getContext('2d');
	ctx.fillStyle = '#c0c0c0';
	ctx.fillRect(0, 0, 512, 512);
	for (let i = 0; i < 3000; i++) {
		ctx.fillStyle = `rgba(255,255,255,${Math.random() * 0.04})`;
		ctx.fillRect(Math.random() * 512, Math.random() * 512, 1, 20);
	}
	const tex = new THREE.CanvasTexture(canvas);
	tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
	return tex;
}
let currentFont = null;
const metalMap = createMetalMap();
let textGroup = new THREE.Group();
scene.add(textGroup);
async function rebuildText(newText) {
	if (!currentFont || !newText) return;
	const oldMeshes = [...textGroup.children];
	oldMeshes.forEach((m) => {
		let s = 1;
		const fade = () => {
			s -= 0.15;
			m.scale.set(s, s, s);
			if (s > 0) requestAnimationFrame(fade);
			else {
				if (m.geometry) m.geometry.dispose();
				if (m.material) m.material.dispose();
				textGroup.remove(m);
			}
		};
		fade();
	});
	const chars = newText.toUpperCase().split('');
	const meshes = [];
	let totalX = 0;
	const spacing = 0.02; // 文字間隔
	chars.forEach((char) => {
		if (char === ' ') {
			totalX += 0.3;
			return;
		}
		const geo = new TextGeometry(char, {
			font: currentFont,
			size: 0.45,
			height: 0.2,
			curveSegments: 12,
			bevelEnabled: true,
			bevelThickness: 0.04,
			bevelSize: 0.03,
			bevelSegments: 5
		});
		geo.computeBoundingBox();
		const mat = new THREE.MeshStandardMaterial({
			color: 0xc0c0c0,
			map: metalMap,
			metalness: 0.9,
			roughness: 0.2
		});
		const mesh = new THREE.Mesh(geo, mat);
		mesh.castShadow = true;
		mesh.userData.offsetX = geo.boundingBox.min.x;
		mesh.userData.width = geo.boundingBox.max.x - geo.boundingBox.min.x;
		mesh.userData.posX = totalX;
		totalX += mesh.userData.width + spacing;
		meshes.push(mesh);
	});
	const centerOffset = (totalX - spacing) / 2;
	meshes.forEach((m, i) => {
		m.position.x = m.userData.posX - centerOffset - m.userData.offsetX;
		m.scale.set(0, 0, 0);
		textGroup.add(m);
		setTimeout(() => {
			let s = 0;
			const anim = () => {
				s += 0.12;
				m.scale.set(s, s, s);
				if (s < 1) requestAnimationFrame(anim);
				else m.scale.set(1, 1, 1);
			};
			anim();
		}, i * 40);
	});
}
const input = document.getElementById('kumonosu-morphInput');
const btn = document.getElementById('kumonosu-morphBtn');
const handleMorph = () => {
	const val = input.value.trim();
	if (val) rebuildText(val);
};
btn.addEventListener('click', handleMorph);
input.addEventListener('keypress', (e) => {
	if (e.key === 'Enter') handleMorph();
});
new FontLoader().load(FONT_URL, f => {
	currentFont = f;
	rebuildText("HAPPY");
});
window.addEventListener('resize', () => {
	adjustCamera();
	renderer.setSize(window.innerWidth, window.innerHeight);
});

function animate() {
	requestAnimationFrame(animate);
	controls.update();
	renderer.render(scene, camera);
}
animate();
入力した英字が金属質感の3D文字に変わるジェネレーター。

Three.jsのTextGeometryとメタルマテリアルで立体文字を生成し、1文字ずつのモーフアニメーション、回転・ズーム操作に対応しています。

Three.jsとGLSLで作る、斜めに流れる多段パララックス画像ギャラリー

<div class="kumonosu-loading-container" id="kumonosu-loading-overlay">
	<div id="kumonosu-loading-text">Loading...</div>
	<div class="kumonosu-progress-bar">
		<div class="kumonosu-progress-fill" id="kumonosu-progress-fill-element"></div>
	</div>
</div>
* {
	margin: 0;
	padding: 0;
	box-sizing: border-box;
}
html, body {
	width: 100%;
	height: 100%;
	overflow: hidden;
	background: #050a10;
	font-family: sans-serif;
}
canvas {
	position: fixed;
	top: 0;
	left: 0;
	width: 100vw;
	height: 100vh;
}
.kumonosu-loading-container {
	position: fixed;
	top: 50%;
	left: 50%;
	transform: translate(-50%, -50%);
	background: rgba(255, 255, 255, 0.9);
	padding: 20px;
	border-radius: 10px;
	z-index: 1000;
	text-align: center;
	box-shadow: 0 4px 20px rgb(0 0 0 / .3);
}
.kumonosu-progress-bar {
	width: 200px;
	height: 4px;
	background: #ddd;
	margin-top: 10px;
	border-radius: 2px;
}
.kumonosu-progress-fill {
	width: 0%;
	height: 100%;
	background: #333;
	border-radius: 2px;
	transition: width 0.2s;
}
::-webkit-scrollbar {
	display: none;
}
let scrollY = 0;
let targetScrollY = 0;
let scrollVelocity = 0;
let materials = [];
let totalImagesToLoad = 0;
let loadedImagesCount = 0;
let meshes = [];
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 10);
const renderer = new THREE.WebGLRenderer({
	antialias: true,
	alpha: true
});
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
camera.position.z = 1;
const BAND_HEIGHT = 120;
const IMAGE_HEIGHT = 100;
const IMAGE_GAP = 20;
const CLONE_COUNT = 3;
const MAX_IMAGE_WIDTH = 300;
const IMAGES_PER_BAND = [8, 12, 9, 13, 14, 10, 9, 13];
const bandConfigs = [{
	offsetY: -385,
	speed: 1.6,
	rotation: 7 * Math.PI / 180
}, {
	offsetY: -275,
	speed: 1.3,
	rotation: 7 * Math.PI / 180
}, {
	offsetY: -165,
	speed: 0.7,
	rotation: 7 * Math.PI / 180
}, {
	offsetY: -55,
	speed: 1.0,
	rotation: 7 * Math.PI / 180
}, {
	offsetY: 55,
	speed: 0.4,
	rotation: 7 * Math.PI / 180
}, {
	offsetY: 165,
	speed: 1.2,
	rotation: 7 * Math.PI / 180
}, {
	offsetY: 275,
	speed: 0.8,
	rotation: 7 * Math.PI / 180
}, {
	offsetY: 385,
	speed: 1.4,
	rotation: 7 * Math.PI / 180
}];

function updateLoading() {
	const progress = (loadedImagesCount / totalImagesToLoad) * 100;
	const progressFill = document.getElementById('kumonosu-progress-fill-element');
	if (progressFill) progressFill.style.width = `${progress}%`;
	if (loadedImagesCount >= totalImagesToLoad) {
		setTimeout(() => {
			const loader = document.getElementById('kumonosu-loading-overlay');
			if (loader) loader.style.display = 'none';
		}, 300);
	}
}

function loadImagesForBand(bandIndex, count) {
	return new Promise((resolve) => {
		const images = [];
		let loaded = 0;
		for (let i = 0; i < count; i++) {
			const img = new Image();
			img.crossOrigin = "anonymous";
			img.src = `https://picsum.photos/400/300?random=${bandIndex}_${i}`;
			const imageObj = {
				loaded: false,
				img: null,
				width: 0,
				height: 0
			};
			images.push(imageObj);
			img.onload = () => {
				const ratio = img.naturalWidth / img.naturalHeight;
				imageObj.width = Math.min(IMAGE_HEIGHT * ratio, MAX_IMAGE_WIDTH);
				imageObj.height = IMAGE_HEIGHT;
				imageObj.img = img;
				imageObj.loaded = true;
				loaded++;
				loadedImagesCount++;
				updateLoading();
				if (loaded === count) resolve(images);
			};
			img.onerror = () => {
				const canvas = document.createElement('canvas');
				canvas.width = 150;
				canvas.height = 100;
				imageObj.img = canvas;
				imageObj.width = 150;
				imageObj.height = 100;
				imageObj.loaded = true;
				loaded++;
				loadedImagesCount++;
				updateLoading();
				if (loaded === count) resolve(images);
			};
		}
	});
}

function createBandTexture(images) {
	let seqWidth = 0;
	images.forEach(img => {
		seqWidth += img.width + IMAGE_GAP;
	});
	const totalWidth = seqWidth * CLONE_COUNT;
	const canvas = document.createElement('canvas');
	canvas.width = totalWidth;
	canvas.height = BAND_HEIGHT;
	const ctx = canvas.getContext('2d');
	for (let c = 0; c < CLONE_COUNT; c++) {
		let x = c * seqWidth;
		images.forEach(img => {
			const y = (BAND_HEIGHT - img.height) / 2;
			ctx.drawImage(img.img, x, y, img.width, img.height);
			x += img.width + IMAGE_GAP;
		});
	}
	const tex = new THREE.Texture(canvas);
	tex.needsUpdate = true;
	return {
		texture: tex,
		totalWidth,
		seqWidth
	};
}
async function initBands() {
	const promises = bandConfigs.map((config, i) => {
		return loadImagesForBand(i, IMAGES_PER_BAND[i]).then(imgs => {
			const {
				texture,
				totalWidth,
				seqWidth
			} = createBandTexture(imgs);
			const mat = new THREE.ShaderMaterial({
				uniforms: {
					uResolution: {
						value: new THREE.Vector2(window.innerWidth, window.innerHeight)
					},
					uTexture: {
						value: texture
					},
					uTextureWidth: {
						value: totalWidth
					},
					uSequenceWidth: {
						value: seqWidth
					},
					uBandHeight: {
						value: BAND_HEIGHT
					},
					uScroll: {
						value: 0
					},
					uSpeed: {
						value: config.speed
					},
					uOffsetY: {
						value: config.offsetY
					},
					uRotation: {
						value: config.rotation
					}
				},
				vertexShader: `varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
				fragmentShader: `
                    precision highp float;
                    uniform vec2 uResolution;
                    uniform sampler2D uTexture;
                    uniform float uTextureWidth;
                    uniform float uSequenceWidth;
                    uniform float uBandHeight;
                    uniform float uScroll;
                    uniform float uSpeed;
                    uniform float uOffsetY;
                    uniform float uRotation;
                    varying vec2 vUv;
                    mat2 rotate2d(float a) { return mat2(cos(a), -sin(a), sin(a), cos(a)); }
                    void main() {
                        vec2 p = vUv * uResolution;
                        float bCenterY = uResolution.y * 0.5 + uOffsetY;
                        float bTop = bCenterY - uBandHeight * 0.5;
                        float bBottom = bCenterY + uBandHeight * 0.5;
                        vec2 rotCenter = vec2(uResolution.x * 0.5, bCenterY);
                        vec2 pRot = rotate2d(uRotation) * (p - rotCenter) + rotCenter;
                        if (pRot.y < bTop || pRot.y > bBottom) discard;
                        float scrollX = mod(pRot.x + uScroll * uSpeed, uSequenceWidth);
                        float texX = (scrollX + uSequenceWidth) / uTextureWidth;
                        float texY = (pRot.y - bTop) / uBandHeight;
                        vec4 col = texture2D(uTexture, vec2(texX, texY));
                        if (col.a < 0.01) discard;
                        gl_FragColor = col;
                    }
                `,
				transparent: true
			});
			materials[i] = mat;
			const mesh = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), mat);
			mesh.position.z = -i * 0.01;
			scene.add(mesh);
			meshes[i] = mesh;
		});
	});
	await Promise.all(promises);
}
let isDragging = false;
let lastY = 0;
const inertia = 0.95;
document.addEventListener('wheel', e => {
	e.preventDefault();
	targetScrollY += e.deltaY;
	scrollVelocity = e.deltaY * 0.15;
}, {
	passive: false
});
document.addEventListener('mousedown', e => {
	isDragging = true;
	lastY = e.clientY;
	scrollVelocity = 0;
	document.body.style.cursor = 'grabbing';
});
document.addEventListener('mousemove', e => {
	if (!isDragging) return;
	const dy = e.clientY - lastY;
	targetScrollY += dy * 2.0;
	lastY = e.clientY;
	scrollVelocity = dy * 0.25;
});
document.addEventListener('mouseup', () => {
	isDragging = false;
	document.body.style.cursor = 'default';
});

function animate() {
	requestAnimationFrame(animate);
	if (!isDragging) {
		targetScrollY += scrollVelocity;
		scrollVelocity *= inertia;
	}
	scrollY += (targetScrollY - scrollY) * 0.1;
	materials.forEach(m => {
		if (m) {
			m.uniforms.uScroll.value = scrollY;
			m.uniforms.uResolution.value.set(window.innerWidth, window.innerHeight);
		}
	});
	renderer.render(scene, camera);
}
window.addEventListener('resize', () => renderer.setSize(window.innerWidth, window.innerHeight));
totalImagesToLoad = IMAGES_PER_BAND.reduce((a, b) => a + b, 0);
initBands().then(animate);
https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js
画像を8段の斜め帯に配置し、スクロールで各段が異なる速度で横に流れるパララックスギャラリー。

Three.js+GLSLで無限ループスクロールを実装し、並列読み込み・慣性・ドラッグ操作に対応しています。

Three.jsとGSAPで作る、画像を使ったスライドパズル

<div id="kumonosu-container">
	<div id="kumonosu-preview">
		<img id="kumonosu-previewImage" src="" alt="Target">
	</div>
	<div id="kumonosu-footer">
		<button id="kumonosu-shuffleBtn">Shuffle</button>
	</div>
	<div id="kumonosu-hint">ピースを右下の空きスペースへ動かしてください</div>
	<div id="kumonosu-message"></div>
</div>
@import url(https://fonts.googleapis.com/css2?family=Inter:wght@600;700&family=Noto+Sans+JP:wght@500;700&display=swap);
* {
	margin: 0;
	padding: 0;
	box-sizing: border-box;
	-webkit-tap-highlight-color: transparent;
	touch-action: none;
	/* ブラウザのスクロール等を無効化 */
}
body {
	font-family: "Inter", "Noto Sans JP", sans-serif;
	overflow: hidden;
	background: #0a0a0a;
	height: 100vh;
	width: 100vw;
}
#kumonosu-container {
	position: relative;
	width: 100%;
	height: 100%;
}
#kumonosu-preview {
	position: absolute;
	top: 20px;
	right: 20px;
	width: 90px;
	height: 90px;
	background: rgba(0, 0, 0, 0.5);
	border-radius: 8px;
	overflow: hidden;
	border: 2px solid rgba(255, 255, 255, 0.2);
	z-index: 10;
}
#kumonosu-preview img {
	width: 100%;
	height: 100%;
	object-fit: cover;
	display: block;
}
#kumonosu-footer {
	position: absolute;
	bottom: 50px;
	left: 0;
	width: 100%;
	display: flex;
	justify-content: center;
	z-index: 20;
}
#kumonosu-shuffleBtn {
	background: #4a9eff;
	color: #fff;
	border: none;
	padding: 16px 50px;
	border-radius: 50px;
	cursor: pointer;
	font-size: 18px;
	font-weight: 700;
	box-shadow: 0 4px 20px rgba(74, 158, 255, 0.4);
}
#kumonosu-hint {
	position: absolute;
	bottom: 20px;
	width: 100%;
	text-align: center;
	color: rgba(255, 255, 255, 0.5);
	font-size: 12px;
	z-index: 10;
}
#kumonosu-message {
	position: absolute;
	top: 50%;
	left: 50%;
	transform: translate(-50%, -50%);
	background: rgba(20, 20, 20, 0.9);
	color: #fff;
	padding: 20px 40px;
	border-radius: 12px;
	opacity: 0;
	transition: opacity 0.3s;
	z-index: 100;
	text-align: center;
	pointer-events: none;
}
#kumonosu-message.kumonosu-show {
	opacity: 1;
}
canvas {
	display: block;
	width: 100%;
	height: 100%;
	cursor: grab;
}
const IMAGE_URL = "https://images.unsplash.com/photo-1570913149827-d2ac84ab3f9a?w=800&h=800&fit=crop";
let scene, camera, renderer;
let puzzlePieces = [];
let draggedPiece = null;
const gridSize = 3;
const pieceSize = 1;
const EMPTY_GOAL = {
	r: 2,
	c: 2
};
let emptyPos = {
	...EMPTY_GOAL
};
let raycaster = new THREE.Raycaster();
let mouse = new THREE.Vector2();
let dragPlane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0);
let planeIntersect = new THREE.Vector3();
let dragOffset = new THREE.Vector3();
async function init() {
	document.getElementById('kumonosu-previewImage').src = IMAGE_URL;
	scene = new THREE.Scene();
	scene.background = new THREE.Color(0x0a0a0a);
	camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
	camera.position.set(0, 0, 6);
	camera.lookAt(0, 0, 0);
	renderer = new THREE.WebGLRenderer({
		antialias: true,
		alpha: true
	});
	renderer.setPixelRatio(window.devicePixelRatio);
	renderer.setSize(window.innerWidth, window.innerHeight);
	renderer.outputColorSpace = THREE.SRGBColorSpace;
	document.getElementById('kumonosu-container').appendChild(renderer.domElement);
	await createPuzzle();
	window.addEventListener('resize', onResize);
	document.getElementById('kumonosu-shuffleBtn').addEventListener('click', shufflePieces);
	// イベント登録
	renderer.domElement.addEventListener('pointerdown', onPointerDown);
	window.addEventListener('pointermove', onPointerMove);
	window.addEventListener('pointerup', onPointerUp);
	animate();
}
async function createPuzzle() {
	const img = new Image();
	img.crossOrigin = "anonymous";
	const fullImage = await new Promise(resolve => {
		img.onload = () => resolve(img);
		img.onerror = () => resolve(null);
		img.src = IMAGE_URL;
	});
	for (let row = 0; row < gridSize; row++) {
		for (let col = 0; col < gridSize; col++) {
			if (row === EMPTY_GOAL.r && col === EMPTY_GOAL.c) continue;
			const piece = createPiece(row, col, fullImage);
			puzzlePieces.push(piece);
			scene.add(piece);
		}
	}
	setTimeout(shufflePieces, 500);
}

function createPiece(row, col, fullImage) {
	const canvas = document.createElement('canvas');
	const ctx = canvas.getContext('2d');
	canvas.width = 256;
	canvas.height = 256;
	if (fullImage) {
		const sw = fullImage.width / gridSize;
		const sh = fullImage.height / gridSize;
		ctx.drawImage(fullImage, col * sw, row * sh, sw, sh, 0, 0, 256, 256);
	}
	const texture = new THREE.CanvasTexture(canvas);
	texture.colorSpace = THREE.SRGBColorSpace;
	const geometry = new THREE.BoxGeometry(pieceSize * 0.98, pieceSize * 0.98, 0.1);
	const sideMat = new THREE.MeshBasicMaterial({
		color: 0x222222
	});
	const frontMat = new THREE.MeshBasicMaterial({
		map: texture
	});
	const materials = [sideMat, sideMat, sideMat, sideMat, frontMat, sideMat];
	const piece = new THREE.Mesh(geometry, materials);
	piece.userData = {
		origRow: row,
		origCol: col,
		currRow: row,
		currCol: col
	};
	updatePosition(piece, true);
	return piece;
}

function updatePosition(piece, instant = false) {
	const tx = (piece.userData.currCol - gridSize / 2 + 0.5) * pieceSize;
	const ty = (gridSize / 2 - 0.5 - piece.userData.currRow) * pieceSize;
	if (instant) {
		piece.position.set(tx, ty, 0);
	} else {
		gsap.to(piece.position, {
			x: tx,
			y: ty,
			z: 0,
			duration: 0.3,
			ease: "power2.out"
		});
	}
}

function shufflePieces() {
	let moves = 0;
	while (moves < 100) {
		const adjacents = puzzlePieces.filter(p => Math.abs(p.userData.currRow - emptyPos.r) + Math.abs(p.userData.currCol - emptyPos.c) === 1);
		const pick = adjacents[Math.floor(Math.random() * adjacents.length)];
		const tr = pick.userData.currRow,
			tc = pick.userData.currCol;
		pick.userData.currRow = emptyPos.r;
		pick.userData.currCol = emptyPos.c;
		emptyPos.r = tr;
		emptyPos.c = tc;
		moves++;
	}
	while (emptyPos.c < EMPTY_GOAL.c) slideLogic(emptyPos.r, emptyPos.c + 1);
	while (emptyPos.r < EMPTY_GOAL.r) slideLogic(emptyPos.r + 1, emptyPos.c);
	puzzlePieces.forEach(p => updatePosition(p));
	showMessage("Shuffled!", 1000);
}

function slideLogic(r, c) {
	const piece = puzzlePieces.find(p => p.userData.currRow === r && p.userData.currCol === c);
	if (piece) {
		const tr = piece.userData.currRow,
			tc = piece.userData.currCol;
		piece.userData.currRow = emptyPos.r;
		piece.userData.currCol = emptyPos.c;
		emptyPos.r = tr;
		emptyPos.c = tc;
	}
}

function onPointerDown(e) {
	updateMouse(e);
	raycaster.setFromCamera(mouse, camera);
	const intersects = raycaster.intersectObjects(puzzlePieces);
	if (intersects.length > 0) {
		const piece = intersects[0].object;
		if (Math.abs(piece.userData.currRow - emptyPos.r) + Math.abs(piece.userData.currCol - emptyPos.c) === 1) {
			draggedPiece = piece;
			raycaster.ray.intersectPlane(dragPlane, planeIntersect);
			dragOffset.copy(draggedPiece.position).sub(planeIntersect);
			gsap.to(draggedPiece.position, {
				z: 0.2,
				duration: 0.1
			});
			document.body.style.cursor = "grabbing";
		}
	}
}

function onPointerMove(e) {
	if (!draggedPiece) return;
	updateMouse(e);
	raycaster.setFromCamera(mouse, camera);
	if (raycaster.ray.intersectPlane(dragPlane, planeIntersect)) {
		draggedPiece.position.x = planeIntersect.x + dragOffset.x;
		draggedPiece.position.y = planeIntersect.y + dragOffset.y;
	}
}

function onPointerUp() {
	if (!draggedPiece) return;
	const ex = (emptyPos.c - gridSize / 2 + 0.5) * pieceSize;
	const ey = (gridSize / 2 - 0.5 - emptyPos.r) * pieceSize;
	const dist = Math.sqrt(Math.pow(draggedPiece.position.x - ex, 2) + Math.pow(draggedPiece.position.y - ey, 2));
	if (dist < 0.6) {
		const tr = draggedPiece.userData.currRow,
			tc = draggedPiece.userData.currCol;
		draggedPiece.userData.currRow = emptyPos.r;
		draggedPiece.userData.currCol = emptyPos.c;
		emptyPos.r = tr;
		emptyPos.c = tc;
	}
	updatePosition(draggedPiece);
	if (puzzlePieces.every(p => p.userData.currRow === p.userData.origRow && p.userData.currCol === p.userData.origCol)) {
		setTimeout(() => showMessage("Completed!", 3000), 400);
	}
	draggedPiece = null;
	document.body.style.cursor = "default";
}

function updateMouse(e) {
	mouse.x = (e.clientX / window.innerWidth) * 2 - 1;
	mouse.y = -(e.clientY / window.innerHeight) * 2 + 1;
}

function showMessage(txt, dur) {
	const m = document.getElementById('kumonosu-message');
	m.textContent = txt;
	m.classList.add('kumonosu-show');
	if (dur) setTimeout(() => m.classList.remove('kumonosu-show'), dur);
}

function onResize() {
	camera.aspect = window.innerWidth / window.innerHeight;
	camera.updateProjectionMatrix();
	renderer.setSize(window.innerWidth, window.innerHeight);
}

function animate() {
	requestAnimationFrame(animate);
	renderer.render(scene, camera);
}
init();
https://cdnjs.cloudflare.com/ajax/libs/three.js/0.160.0/three.min.js
https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js
画像を3×3に分割し、Three.jsの3D空間上でドラッグ操作で遊べるスライドパズル。

GSAPによるなめらかなピース移動、シャッフル機能、完成判定、プレビュー表示を備えたインタラクティブなミニゲームです。