Three.jsで魅せるCSS・JavaScriptまとめ|3D演出・インタラクション事例集

特集

Three.jsで魅せるCSS・JavaScriptまとめ|3D演出・インタラクション事例集

投稿日2026/04/30

更新日2026/4/21

Webサイトの表現力は、年々大きく進化しています。
特にThree.jsを使った3D表現は、これまでのCSSやJavaScriptだけでは実現できなかった没入感やインタラクションを可能にしています。

本ページでは、Three.jsを活用したさまざまな表現事例やチュートリアル記事をまとめました。
背景アニメーションやスクロール連動の演出、マウス操作によるインタラクションなど、実務や個人制作のヒントになるアイデアを幅広く紹介しています。

「少しリッチなUIを作りたい」「他と差別化できる表現を取り入れたい」と考えている方は、ぜひ参考にしてみてください。

CSSとJSで作る 3Dパーティクル文字モーフィング

<div id="kumonosu-input-container">
	<input type="text" id="kumonosu-text-input" placeholder="Type here..." maxlength="15">
	<button id="kumonosu-morph-btn">Morph</button>
</div>
body {
	margin: 0;
	padding: 0;
	overflow: hidden;
	background-color: #000;
	font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
#kumonosu-input-container {
	position: absolute;
	bottom: 40px;
	left: 50%;
	transform: translateX(-50%);
	display: flex;
	gap: 10px;
	z-index: 100;
}
input {
	background: rgba(255, 255, 255, 0.1);
	border: 1px solid rgba(255, 255, 255, 0.3);
	border-radius: 4px;
	color: white;
	padding: 10px 20px;
	font-size: 16px;
	outline: none;
	width: 250px;
	backdrop-filter: blur(5px);
}
button {
	background: #6366f1;
	border: none;
	color: white;
	padding: 10px 20px;
	border-radius: 4px;
	cursor: pointer;
	font-weight: bold;
	transition: background 0.3s;
}
button:hover {
	background: #4f46e5;
}
canvas {
	display: block;
}
let scene, camera, renderer, particles;
const count = 10000; // パーティクル数
// 状態管理
let targetPositions = new Float32Array(count * 3);
let currentPositions = new Float32Array(count * 3);
let spherePositions = new Float32Array(count * 3);
let textPositions = new Float32Array(count * 3);
let morphProgress = 0;
let isTextMode = false;

function init() {
	scene = new THREE.Scene();
	camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
	camera.position.z = 6;
	renderer = new THREE.WebGLRenderer({
		antialias: true
	});
	renderer.setSize(window.innerWidth, window.innerHeight);
	renderer.setPixelRatio(window.devicePixelRatio);
	document.body.appendChild(renderer.domElement);
	createParticles();
	// イベント設定
	window.addEventListener('resize', onWindowResize);
	document.getElementById('kumonosu-morph-btn').addEventListener('click', updateTextTarget);
	document.getElementById('kumonosu-text-input').addEventListener('keypress', (e) => {
		if (e.key === 'Enter') updateTextTarget();
	});
	animate();
}

function createParticles() {
	const geometry = new THREE.BufferGeometry();
	const colors = new Float32Array(count * 3);
	// 1. 球体位置の生成
	const phi = Math.PI * (3 - Math.sqrt(5));
	for (let i = 0; i < count; i++) {
		const y = 1 - (i / (count - 1)) * 2;
		const radius = Math.sqrt(1 - y * y);
		const theta = phi * i;
		const x = Math.cos(theta) * radius;
		const z = Math.sin(theta) * radius;
		const scale = 2.5;
		spherePositions[i * 3] = x * scale;
		spherePositions[i * 3 + 1] = y * scale;
		spherePositions[i * 3 + 2] = z * scale;
		// 初期状態は球体
		currentPositions[i * 3] = spherePositions[i * 3];
		currentPositions[i * 3 + 1] = spherePositions[i * 3 + 1];
		currentPositions[i * 3 + 2] = spherePositions[i * 3 + 2];
		// 色の初期設定
		const color = new THREE.Color();
		color.setHSL(0.6 + (y * 0.1), 0.8, 0.6);
		colors[i * 3] = color.r;
		colors[i * 3 + 1] = color.g;
		colors[i * 3 + 2] = color.b;
	}
	geometry.setAttribute('position', new THREE.BufferAttribute(currentPositions, 3));
	geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
	const material = new THREE.PointsMaterial({
		size: 0.02,
		vertexColors: true,
		transparent: true,
		opacity: 0.8,
		blending: THREE.AdditiveBlending
	});
	particles = new THREE.Points(geometry, material);
	scene.add(particles);
	// 初期のターゲットは球体
	targetPositions.set(spherePositions);
}

function updateTextTarget() {
	const text = document.getElementById('kumonosu-text-input').value;
	if (!text) {
		isTextMode = false;
		targetPositions.set(spherePositions);
		return;
	}
	// オフスクリーンキャンバスでテキストを描画して座標を取得
	const canvas = document.createElement('canvas');
	const ctx = canvas.getContext('2d');
	canvas.width = 400;
	canvas.height = 100;
	ctx.fillStyle = 'white';
	ctx.font = 'bold 60px Arial';
	ctx.textAlign = 'center';
	ctx.textBaseline = 'middle';
	ctx.fillText(text, 200, 50);
	const imageData = ctx.getImageData(0, 0, 400, 100).data;
	const points = [];
	// 白いピクセル(テキスト部分)を抽出
	for (let y = 0; y < 100; y += 1) {
		for (let x = 0; x < 400; x += 1) {
			const alpha = imageData[(y * 400 + x) * 4 + 3];
			if (alpha > 128) {
				// 座標を3D空間用に変換 (-5 to 5 程度)
				points.push({
					x: (x - 200) / 30,
					y: -(y - 50) / 30,
					z: (Math.random() - 0.5) * 0.5 // 厚み
				});
			}
		}
	}
	// パーティクル数に合わせてテキスト座標を割り当て
	for (let i = 0; i < count; i++) {
		const p = points[i % points.length];
		textPositions[i * 3] = p.x;
		textPositions[i * 3 + 1] = p.y;
		textPositions[i * 3 + 2] = p.z;
	}
	isTextMode = true;
	targetPositions.set(textPositions);
}

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

function animate() {
	requestAnimationFrame(animate);
	const positions = particles.geometry.attributes.position.array;
	// 各パーティクルをターゲットに向けて移動(イージング)
	for (let i = 0; i < count * 3; i++) {
		positions[i] += (targetPositions[i] - positions[i]) * 0.08;
	}
	particles.geometry.attributes.position.needsUpdate = true;
	// テキストモードでない時は回転させる
	if (!isTextMode) {
		particles.rotation.y += 0.005;
		particles.rotation.x += 0.002;
	} else {
		// テキストモード時は正面を向かせる(ゆっくり戻る)
		particles.rotation.y *= 0.95;
		particles.rotation.x *= 0.95;
	}
	renderer.render(scene, camera);
}
window.onload = init;
https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js
Three.jsを用いた3Dパーティクル表現で、黄金比配置による回転する球体を初期状態として表示し、ユーザーのテキスト入力に応じて文字形状へ滑らかにモーフィングします。

文字はCanvas上に描画してピクセル情報から座標を生成し、各パーティクルはイージング処理によって自然に移動します。

テキスト表示中は自動回転を抑えて正面を向かせ、WebGL対応ブラウザ上でCDN読み込みのみで動作する構成となっています。

JS(WebGL)で作る立体的な六角形を照らすリアルな光

<div id="app">
  <canvas id="webgl-canvas"></canvas>

  <div class="hero">
    <h1>HEXAGONAL</h1>
    <h2>LIGHT</h2>
  </div>
</div>
body, html, #app {
    margin: 0;
    padding: 0;
    width: 100%;
    height: 100%;
    overflow: hidden;
    background-color: #000;
}
#app {
    position: relative;
    font-family: "Montserrat", sans-serif;
}
#webgl-canvas {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    z-index: 0;
}
.hero {
    position: relative;
    z-index: 1;
    height: 100vh;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    pointer-events: none;
    padding: 0 20px;
    /* スマホ時の端の余白 */
    text-align: center;
}
h1, h2 {
    margin: 0;
    color: white;
    text-transform: uppercase;
    letter-spacing: 4px;
    text-shadow: 0 0 15px rgba(255, 255, 255, 0.4), 0 0 30px rgba(177, 19, 19, 0.5);
    line-height: 0.95;
    user-select: none;
}
/* clamp(最小値, 推奨値, 最大値) */
h1 {
    font-size: clamp(40px, 10vw, 70px);
    font-weight: 900;
}
h2 {
    font-size: clamp(20px, 6vw, 40px);
    font-weight: 700;
    opacity: 0.9;
    margin-top: 10px;
}
import Grid1Background from 'https://cdn.jsdelivr.net/npm/threejs-components@0.0.16/build/backgrounds/grid1.cdn.min.js'
const canvasElement = document.getElementById('webgl-canvas');
const bg = Grid1Background(canvasElement);
bg.grid.setColors([
    0xB11313, 0xB11313, 0xB11313,
    0x000000,
    0x2B3784, 0x2B3784,
    0x000000
]);
bg.grid.light1.color.set(0xFFFFFF);
bg.grid.light1.intensity = 1800;
bg.grid.light2.color.set(0xFFFFFF);
bg.grid.light2.intensity = 600;
window.addEventListener('resize', () => {
    if (bg.resize) bg.resize();
});
WebGLで描画した立体的な六角形グリッドを、リアルな光で照らす表現デモ。
スポットライトによる陰影によって、形状の立体感と存在感を強調しています。

JSで作るマウスに合わせて動く柔らかなスポットライト

body,
html {
	margin: 0;
	padding: 0;
	overflow: hidden;
	background: #000;
}
#ev-canvas {
	display: block;
	width: 100vw;
	height: 100vh;
	cursor: none;
}
/**
 * 【動画版:最高画質・コントラスト重視 パラメータ】 
 */
const CONFIG = {
    // 動画のパス(mp4推奨)
    videoUrl: "/wp-content/uploads/2026/01/127_movie.mp4", 
    
    // --- サイズ調整 ---
    displayScale: 1.2,        // 動画は解像度が画像より低いため 1.0 前後がおすすめ
    
    // --- 3Dティルト ---
    tiltAmount: 0.12,         
    
    // --- 光源の設定 ---
    ambientBrightness: 0.01,  
    lightStrength: 3.5,       
    lightRadius: 0.3,        
    coreSize: 0.008,          
    
    // --- 反射・質感(動画のノイズを抑えるため少し調整) ---
    specularStrength: 1.8,    
    shininess: 15.0,          
    lightHeight: 0.15,        
    
    // --- グロー ---
    midColorHex: 0xff8c3c,    
    wideColorHex: 0x2244ff,   
    
    lerpFactor: 0.1          
};

class EmissiveVideoApp {
    constructor() {
        this.midColor = new THREE.Color(CONFIG.midColorHex);
        this.wideColor = new THREE.Color(CONFIG.wideColorHex);

        this.renderer = new THREE.WebGLRenderer({ 
            antialias: true, 
            alpha: true, 
            powerPreference: "high-performance",
            precision: "highp"
        });
        
        this.renderer.domElement.id = 'ev-canvas';
        this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
        this.renderer.setSize(window.innerWidth, window.innerHeight);
        document.body.appendChild(this.renderer.domElement);

        this.scene = new THREE.Scene();
        this.camera = new THREE.PerspectiveCamera(40, window.innerWidth / window.innerHeight, 0.1, 100);
        this.camera.position.z = 3;

        this.mouse = new THREE.Vector2(0.5, 0.5);
        this.targetMouse = new THREE.Vector2(0.5, 0.5);
        this.init();
    }

    async init() {
        // --- ビデオ要素の作成 ---
        const video = document.createElement('video');
        video.src = CONFIG.videoUrl;
        video.loop = true;
        video.muted = true; // 自動再生にはミュートが必須
        video.playsInline = true;
        video.crossOrigin = "anonymous";
        video.play();

        // 動画の読み込み完了を待つ
        video.onloadeddata = () => {
            const tex = new THREE.VideoTexture(video);
            
            // 動画のボヤけを防ぐ設定
            const maxAnisotropy = this.renderer.capabilities.getMaxAnisotropy();
            tex.anisotropy = maxAnisotropy;
            tex.minFilter = THREE.LinearFilter;
            tex.magFilter = THREE.LinearFilter;
            
            this.texture = tex;
            this.videoElement = video; // 比率取得用
            this.initMesh();
            this.initEvents();
            this.animate();
        };
    }

    getFitSize() {
        const fovRad = (this.camera.fov * Math.PI) / 180;
        const viewHeight = 2 * Math.tan(fovRad / 2) * this.camera.position.z;
        const viewWidth = viewHeight * this.camera.aspect;
        
        // 動画の縦横比を使用
        const videoAspect = this.videoElement.videoWidth / this.videoElement.videoHeight;
        const screenAspect = window.innerWidth / window.innerHeight;

        let width, height;
        if (videoAspect > screenAspect) {
            width = viewWidth; height = viewWidth / videoAspect;
        } else {
            height = viewHeight; width = viewHeight * videoAspect;
        }
        return { width: width * CONFIG.displayScale, height: height * CONFIG.displayScale };
    }

    initMesh() {
        const size = this.getFitSize();

        const vertexShader = `
            varying vec2 vUv;
            void main() {
                vUv = uv;
                gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
            }
        `;

        const fragmentShader = `
            precision highp float;
            uniform sampler2D uTex;
            uniform vec2 uMouse;
            uniform vec2 uResolution;
            uniform float uAmbient;
            uniform float uLightStrength;
            uniform float uLightRadius;
            uniform float uSpecularStrength;
            uniform float uShininess;
            uniform float uLightHeight;
            uniform float uCoreSize;
            uniform vec3  uMidColor;
            uniform vec3  uWideColor;
            varying vec2 vUv;

            float getLum(vec2 uv) {
                return dot(texture2D(uTex, uv).rgb, vec3(0.299, 0.587, 0.114));
            }

            vec3 getReflectNormal(vec2 uv) {
                float eps = 0.003; 
                float h = getLum(uv);
                float hx = getLum(uv + vec2(eps, 0.0));
                float hy = getLum(uv + vec2(0.0, eps));
                // 動画はノイズが出やすいため、凹凸強調を0.4程度に抑える
                return normalize(vec3((h - hx) * 0.4, (h - hy) * 0.4, 0.1));
            }

            void main() {
                vec2 aspect = vec2(uResolution.x / uResolution.y, 1.0);
                vec2 mouse = uMouse * aspect;
                vec2 uv = vUv * aspect;
                float dist = distance(mouse, uv);

                vec3 baseColor = texture2D(uTex, vUv).rgb;

                float lightFalloff = 1.0 / (1.0 + pow(dist / uLightRadius, 4.5));
                vec3 litImage = baseColor * lightFalloff * uLightStrength;

                vec3 normal = getReflectNormal(vUv);
                vec3 lightDir = normalize(vec3(mouse - uv, uLightHeight));
                vec3 viewDir = vec3(0.0, 0.0, 1.0);
                vec3 halfDir = normalize(lightDir + viewDir);
                float spec = pow(max(dot(normal, halfDir), 0.0), uShininess);
                
                vec3 reflection = vec3(spec) * uSpecularStrength * lightFalloff * (baseColor + 0.2);

                float core = exp(-(dist * dist) / (2.0 * uCoreSize * uCoreSize)) * 2.5;
                float midGlow = 1.0 / (1.0 + pow(dist / 0.18, 3.5));
                vec3 glowEffect = (uMidColor * midGlow * 0.5);

                vec3 finalColor = (baseColor * uAmbient) + litImage + reflection + vec3(core) + glowEffect;
                
                float edge = smoothstep(0.0, 0.005, vUv.x) * smoothstep(1.0, 0.995, vUv.x) *
                             smoothstep(0.0, 0.005, vUv.y) * smoothstep(1.0, 0.995, vUv.y);

                gl_FragColor = vec4(finalColor * edge, 1.0);
            }
        `;

        this.material = new THREE.ShaderMaterial({
            transparent: true,
            uniforms: {
                uTex: { value: this.texture },
                uMouse: { value: this.mouse },
                uResolution: { value: new THREE.Vector2(window.innerWidth, window.innerHeight) },
                uAmbient: { value: CONFIG.ambientBrightness },
                uLightStrength: { value: CONFIG.lightStrength },
                uLightRadius: { value: CONFIG.lightRadius },
                uSpecularStrength: { value: CONFIG.specularStrength },
                uShininess: { value: CONFIG.shininess },
                uLightHeight: { value: CONFIG.lightHeight },
                uCoreSize: { value: CONFIG.coreSize },
                uMidColor: { value: this.midColor },
                uWideColor: { value: this.wideColor }
            },
            vertexShader,
            fragmentShader
        });

        this.mesh = new THREE.Mesh(new THREE.PlaneGeometry(size.width, size.height), this.material);
        this.scene.add(this.mesh);
    }

    initEvents() {
        window.addEventListener('mousemove', (e) => {
            this.targetMouse.set(e.clientX / window.innerWidth, 1.0 - (e.clientY / window.innerHeight));
        });
        window.addEventListener('resize', () => {
            this.camera.aspect = window.innerWidth / window.innerHeight;
            this.camera.updateProjectionMatrix();
            this.renderer.setSize(window.innerWidth, window.innerHeight);
            const size = this.getFitSize();
            this.mesh.geometry.dispose();
            this.mesh.geometry = new THREE.PlaneGeometry(size.width, size.height);
            this.material.uniforms.uResolution.value.set(window.innerWidth, window.innerHeight);
        });
    }

    animate() {
        requestAnimationFrame(() => this.animate());
        this.mouse.lerp(this.targetMouse, CONFIG.lerpFactor);
        this.mesh.rotation.x = (this.mouse.y - 0.5) * CONFIG.tiltAmount;
        this.mesh.rotation.y = -(this.mouse.x - 0.5) * CONFIG.tiltAmount;
        this.renderer.render(this.scene, this.camera);
    }
}

window.addEventListener('load', () => {
    if (typeof THREE !== 'undefined') new EmissiveVideoApp();
});
https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js
Three.js とフラグメントシェーダによる高品質なライティングデモです。
マウス位置を発光体として扱い、動画テクスチャに対して自然な反射と減衰、色付きグローを加えることで、円の輪郭が見えない滑らかな光表現を実現しています。

JSで作るみんなで楽しく自由に回せるルービックキューブ

<div id="kumonosu-ui">
    <h1 class="kumonosu-title">Rubik's Cube</h1>
</div>
<div id="kumonosu-controls">
    <button id="kumonosu-shuffle-btn" class="kumonosu-btn">Shuffle</button>
    <button id="kumonosu-solve-btn" class="kumonosu-btn">Solve</button>
    <button id="kumonosu-reset-btn" class="kumonosu-btn">Reset</button>
</div>
<div id="kumonosu-game-container"></div>
body {
    margin: 0;
    overflow: hidden;
    background: #050505;
    font-family: 'Segoe UI', 'Hiragino Kaku Gothic ProN', sans-serif;
    user-select: none;
}
#kumonosu-game-container {
    width: 100vw;
    height: 100vh;
    cursor: grab;
}
#kumonosu-game-container:active {
    cursor: grabbing;
}
#kumonosu-ui {
    position: absolute;
    top: 5%;
    text-align: center;
    width: 100%;
    pointer-events: none;
    color: white;
}
.kumonosu-title {
    font-size: 2rem;
    font-weight: 900;
    letter-spacing: 0.3rem;
    opacity: 0.7;
    margin-bottom: 10px;
    text-transform: uppercase;
}
#kumonosu-controls {
    position: absolute;
    bottom: 8%;
    width: 100%;
    display: flex;
    justify-content: center;
    gap: 15px;
    pointer-events: none;
    flex-wrap: wrap;
}
.kumonosu-btn {
    pointer-events: auto;
    padding: 12px 20px;
    font-size: 0.85rem;
    font-weight: bold;
    color: white;
    background: rgba(255, 255, 255, 0.1);
    border: 1px solid rgba(255, 255, 255, 0.3);
    border-radius: 30px;
    cursor: pointer;
    backdrop-filter: blur(5px);
    transition: all 0.3s ease;
    text-transform: uppercase;
    letter-spacing: 0.1rem;
    outline: none;
    min-width: 100px;
}
.kumonosu-btn:hover {
    background: rgba(255, 255, 255, 0.2);
    border-color: rgba(255, 255, 255, 0.8);
    transform: translateY(-2px);
}
.kumonosu-btn:active {
    transform: translateY(0);
}
/* Solveボタンを目立たせる */
#kumonosu-solve-btn {
    background: rgba(20, 164, 90, 0.2);
    border-color: rgba(20, 164, 90, 0.5);
}
#kumonosu-solve-btn:hover {
    background: rgba(20, 164, 90, 0.4);
    border-color: rgba(20, 164, 90, 1);
}
class KumonosuRubiksGame {
    constructor() {
        this.container = document.getElementById('kumonosu-game-container');
        this.scene = new THREE.Scene();
        
        this.camera = new THREE.PerspectiveCamera(35, window.innerWidth / window.innerHeight, 0.1, 1000);
        this.camera.position.set(8, 9, 15);
        this.camera.lookAt(0, -0.5, 0);
        this.scene.add(this.camera);

        this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
        this.renderer.setSize(window.innerWidth, window.innerHeight);
        this.renderer.setPixelRatio(window.devicePixelRatio);
        this.renderer.shadowMap.enabled = true;
        this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
        this.container.appendChild(this.renderer.domElement);

        this.displayGroup = new THREE.Group();
        this.cubeRoot = new THREE.Group();
        this.displayGroup.add(this.cubeRoot);
        this.scene.add(this.displayGroup);

        // 操作履歴を保存する配列
        this.moveHistory = [];

        this.initLights();
        this.initCube();
        this.initGround();
        
        this.raycaster = new THREE.Raycaster();
        this.mouse = new THREE.Vector2();
        
        this.isAnimating = false;
        this.dragMode = null;
        this.lastMousePos = new THREE.Vector2();
        this.idleTimer = 0;

        this.initEvents();
        this.animate();
    }

    initLights() {
        this.scene.add(new THREE.AmbientLight(0xffffff, 0.3));
        this.mainLight = new THREE.DirectionalLight(0xffffff, 1.2);
        this.mainLight.position.set(-10, 10, 5); 
        this.mainLight.castShadow = true;
        this.mainLight.shadow.mapSize.set(1024, 1024);
        this.camera.add(this.mainLight);

        const fillLight = new THREE.PointLight(0x99aaff, 0.6);
        fillLight.position.set(10, -10, -5);
        this.camera.add(fillLight);
    }

    initGround() {
        const plane = new THREE.Mesh(
            new THREE.PlaneGeometry(100, 100),
            new THREE.ShadowMaterial({ opacity: 0.3 })
        );
        plane.rotation.x = -Math.PI / 2;
        plane.position.y = -5;
        plane.receiveShadow = true;
        this.scene.add(plane);
    }

    createStickerTexture(color) {
        const canvas = document.createElement('canvas');
        canvas.width = 512; canvas.height = 512;
        const ctx = canvas.getContext('2d');
        ctx.fillStyle = '#000';
        ctx.fillRect(0, 0, 512, 512);
        const border = 40; const radius = 50; 
        ctx.fillStyle = color;
        ctx.beginPath();
        ctx.roundRect(border, border, 512-border*2, 512-border*2, radius);
        ctx.fill();
        ctx.fillStyle = 'rgba(255, 255, 255, 0.12)';
        ctx.beginPath();
        ctx.roundRect(border, 400, 512-border*2, 50, [0, 0, radius, radius]);
        ctx.fill();
        return new THREE.CanvasTexture(canvas);
    }

    initCube() {
        this.cubelets = [];
        const colors = { 
            red: '#d51532', orange: '#f48c34', white: '#e6e6e6', 
            yellow: '#f4dc47', green: '#14a45a', blue: '#0c53a6', internal: '#111' 
        };
        const tex = { 
            r: this.createStickerTexture(colors.red), l: this.createStickerTexture(colors.orange), 
            t: this.createStickerTexture(colors.white), b: this.createStickerTexture(colors.yellow), 
            f: this.createStickerTexture(colors.green), bk: this.createStickerTexture(colors.blue), 
            k: this.createStickerTexture(colors.internal) 
        };

        const spacing = 1.02;
        for (let x = -1; x <= 1; x++) {
            for (let y = -1; y <= 1; y++) {
                for (let z = -1; z <= 1; z++) {
                    const mats = [
                        new THREE.MeshStandardMaterial({ map: x === 1 ? tex.r : tex.k, roughness: 0.4 }),
                        new THREE.MeshStandardMaterial({ map: x === -1 ? tex.l : tex.k, roughness: 0.4 }),
                        new THREE.MeshStandardMaterial({ map: y === 1 ? tex.t : tex.k, roughness: 0.4 }),
                        new THREE.MeshStandardMaterial({ map: y === -1 ? tex.b : tex.k, roughness: 0.4 }),
                        new THREE.MeshStandardMaterial({ map: z === 1 ? tex.f : tex.k, roughness: 0.4 }),
                        new THREE.MeshStandardMaterial({ map: z === -1 ? tex.bk : tex.k, roughness: 0.4 })
                    ];
                    const cubelet = new THREE.Mesh(new THREE.BoxGeometry(0.98, 0.98, 0.98), mats);
                    cubelet.position.set(x * spacing, y * spacing, z * spacing);
                    cubelet.castShadow = true;
                    cubelet.receiveShadow = true;
                    
                    cubelet.userData.homePos = cubelet.position.clone();
                    cubelet.userData.homeQuat = cubelet.quaternion.clone();

                    this.cubeRoot.add(cubelet);
                    this.cubelets.push(cubelet);
                }
            }
        }
    }

    initEvents() {
        window.addEventListener('resize', () => {
            this.camera.aspect = window.innerWidth / window.innerHeight;
            this.camera.updateProjectionMatrix();
            this.renderer.setSize(window.innerWidth, window.innerHeight);
        });

        document.getElementById('kumonosu-shuffle-btn').addEventListener('click', () => this.shuffle());
        document.getElementById('kumonosu-reset-btn').addEventListener('click', () => this.reset());
        document.getElementById('kumonosu-solve-btn').addEventListener('click', () => this.solve());

        const getPos = (e) => e.touches ? {x: e.touches[0].clientX, y: e.touches[0].clientY} : {x: e.clientX, y: e.clientY};
        
        const onDown = (e) => {
            if (this.isAnimating) return;
            const pos = getPos(e);
            this.mouse.x = (pos.x / window.innerWidth) * 2 - 1;
            this.mouse.y = -(pos.y / window.innerHeight) * 2 + 1;
            this.raycaster.setFromCamera(this.mouse, this.camera);
            
            const intersects = this.raycaster.intersectObjects(this.cubelets);
            this.lastMousePos.set(pos.x, pos.y);
            this.idleTimer = 0;

            if (intersects.length > 0) {
                this.dragMode = 'layer';
                this.intersectedObject = intersects[0].object;
                this.intersectedFaceNormal = intersects[0].face.normal.clone().applyQuaternion(this.intersectedObject.getWorldQuaternion(new THREE.Quaternion()));
                this.dragStartPos = new THREE.Vector2(pos.x, pos.y);
            } else {
                this.dragMode = 'view';
            }
        };

        const onMove = (e) => {
            if (!this.dragMode) return;
            const pos = getPos(e);
            const dx = pos.x - this.lastMousePos.x;
            const dy = pos.y - this.lastMousePos.y;

            if (this.dragMode === 'view') {
                this.displayGroup.rotation.y += dx * 0.01;
                this.displayGroup.rotation.x += dy * 0.01;
            }
            this.lastMousePos.set(pos.x, pos.y);
            this.idleTimer = 0;
        };

        const onUp = () => {
            if (this.dragMode === 'layer') {
                const dx = this.lastMousePos.x - this.dragStartPos.x;
                const dy = this.lastMousePos.y - this.dragStartPos.y;
                if (Math.sqrt(dx*dx + dy*dy) > 15) this.calculateLayerRotation(dx, dy);
            }
            this.dragMode = null;
        };

        this.container.addEventListener('mousedown', onDown);
        window.addEventListener('mousemove', onMove);
        window.addEventListener('mouseup', onUp);
        this.container.addEventListener('touchstart', onDown, {passive: false});
        window.addEventListener('touchmove', onMove, {passive: false});
        window.addEventListener('touchend', onUp);
    }

    calculateLayerRotation(dx, dy) {
        const worldDrag3D = new THREE.Vector3(dx, -dy, 0).unproject(this.camera).sub(new THREE.Vector3(0,0,0).unproject(this.camera)).normalize();
        const rotAxisWorld = new THREE.Vector3().crossVectors(this.intersectedFaceNormal, worldDrag3D);
        const localRotAxis = rotAxisWorld.clone().applyQuaternion(this.displayGroup.quaternion.clone().invert());
        
        let finalAxis = 'x', maxVal = 0;
        ['x', 'y', 'z'].forEach(a => { 
            if (Math.abs(localRotAxis[a]) > maxVal) { 
                maxVal = Math.abs(localRotAxis[a]); 
                finalAxis = a; 
            } 
        });
        
        const val = Math.round(this.intersectedObject.position[finalAxis] * 100) / 100;
        const dir = localRotAxis[finalAxis] > 0 ? 1 : -1;
        this.rotateLayer(finalAxis, val, dir, 0.4, true);
    }

    // --- 回転ロジック(履歴保存機能付き) ---
    rotateLayer(axis, value, direction, duration = 0.4, record = true) {
        return new Promise(resolve => {
            this.isAnimating = true;
            if (record) {
                this.moveHistory.push({ axis, value, direction });
            }

            const targets = this.cubelets.filter(c => Math.abs(c.position[axis] - value) < 0.1);
            const pivot = new THREE.Group();
            this.cubeRoot.add(pivot);
            targets.forEach(t => pivot.attach(t));
            
            gsap.to(pivot.rotation, { 
                [axis]: (Math.PI / 2) * direction, 
                duration: duration, 
                ease: "power2.inOut", 
                onComplete: () => {
                    targets.forEach(t => {
                        this.cubeRoot.attach(t);
                        t.position.set(Math.round(t.position.x*100)/100, Math.round(t.position.y*100)/100, Math.round(t.position.z*100)/100);
                        t.updateMatrix();
                        const m = t.matrix.elements;
                        for(let i=0; i<12; i++) if (i % 4 !== 3) m[i] = Math.round(m[i]);
                        t.matrix.decompose(t.position, t.quaternion, t.scale);
                    });
                    this.cubeRoot.remove(pivot);
                    this.isAnimating = false;
                    resolve();
                }
            });
        });
    }

    async shuffle() {
        if (this.isAnimating) return;
        const axes = ['x', 'y', 'z'], layers = [-1.02, 0, 1.02], dirs = [1, -1];
        for (let i = 0; i < 15; i++) {
            const a = axes[Math.floor(Math.random()*3)];
            const l = layers[Math.floor(Math.random()*3)];
            const d = dirs[Math.floor(Math.random()*2)];
            await this.rotateLayer(a, l, d, 0.15, true);
        }
    }

    // 正解(履歴を逆再生)
    async solve() {
        if (this.isAnimating || this.moveHistory.length === 0) return;
        
        // 履歴をコピーして逆順にする
        const historyToUndo = [...this.moveHistory].reverse();
        this.moveHistory = []; // 解決中に新しい履歴が溜まらないようクリア

        for (const move of historyToUndo) {
            // 同じ軸・同じ層を、逆方向に回転させる
            await this.rotateLayer(move.axis, move.value, -move.direction, 0.2, false);
        }
    }

    reset() {
        if (this.isAnimating) return;
        this.moveHistory = [];
        gsap.to(this.displayGroup.rotation, { x: 0, y: 0, z: 0, duration: 1, ease: "power2.inOut" });
        this.cubelets.forEach(c => {
            gsap.to(c.position, { x: c.userData.homePos.x, y: c.userData.homePos.y, z: c.userData.homePos.z, duration: 1, ease: "power2.inOut" });
            gsap.to(c.quaternion, { x: c.userData.homeQuat.x, y: c.userData.homeQuat.y, z: c.userData.homeQuat.z, w: c.userData.homeQuat.w, duration: 1, ease: "power2.inOut" });
        });
        this.idleTimer = 0;
    }

    animate() {
        requestAnimationFrame(() => this.animate());
        this.idleTimer++;
        if (!this.dragMode && !this.isAnimating && this.idleTimer > 180) {
            this.displayGroup.rotation.y += 0.003;
            this.displayGroup.rotation.x += 0.001;
        }
        this.renderer.render(this.scene, this.camera);
    }
}

window.onload = () => new KumonosuRubiksGame();
https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js
https://cdnjs.cloudflare.com/ajax/libs/gsap/3.9.1/gsap.min.js
Three.jsで実装した、自由に回せるルービックキューブのインタラクティブデモ。

ドラッグ操作による回転に加え、シャッフルや自動復元機能も備えています。

JS(WebGL)で作るガラスの中に浮かぶ写真の3Dクリスタル

body {
    margin: 0;
    overflow: hidden;
    background-color: #020202;
}
canvas {
    display: block;
}
import * as THREE from 'https://esm.sh/three@0.170.0';
import { OrbitControls } from 'https://esm.sh/three@0.170.0/examples/jsm/controls/OrbitControls.js';
import { RoundedBoxGeometry } from 'https://esm.sh/three@0.170.0/examples/jsm/geometries/RoundedBoxGeometry.js';
import { RGBELoader } from 'https://esm.sh/three@0.170.0/examples/jsm/loaders/RGBELoader.js';

// ==========================================
// 設定
// ==========================================

// 1. 中央に表示する写真のURL
const PHOTO_URL = 'https://kumonosu.net/wp-content/uploads/2026/01/IMG_1298.jpg.webp';

// 2. 反射させる環境マップ
const ENV_URL = 'https://dl.polyhaven.org/file/ph-assets/HDRIs/hdr/1k/soliltude_1k.hdr';

const settings = {
    photoScale: 1.2,
    envIntensity: 1.5, // 反射の明るさ
    rotationSpeed: 0.5
};

// --- SCENE SETUP ---
const scene = new THREE.Scene();
scene.background = new THREE.Color('#020202');

const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 100);
camera.position.set(0, 0, 4);

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.0;
document.body.appendChild(renderer.domElement);

const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;

// --- 360° ENVIRONMENT (Reflections) ---
const rgbeLoader = new RGBELoader();
rgbeLoader.load(ENV_URL, (texture) => {
    texture.mapping = THREE.EquirectangularReflectionMapping;
    scene.environment = texture;
    scene.environmentIntensity = settings.envIntensity;
});

// --- STARK LIGHTS ---
const arcBlue = new THREE.PointLight(0x00ffff, 8, 15); 
arcBlue.position.set(2, 3, 4);
scene.add(arcBlue);

const ironRed = new THREE.PointLight(0xff0000, 6, 15); 
ironRed.position.set(-3, -2, 3);
scene.add(ironRed);

const group = new THREE.Group();
scene.add(group);

// --- PHOTO OBJECT ---
const photoMat = new THREE.MeshStandardMaterial({ 
    side: THREE.DoubleSide, 
    roughness: 0.2,
    metalness: 0.2
});
const photoMesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), photoMat);
group.add(photoMesh);

const textureLoader = new THREE.TextureLoader();
textureLoader.load(PHOTO_URL, (tex) => {
    tex.colorSpace = THREE.SRGBColorSpace;
    photoMat.map = tex;
    const aspect = tex.image.width / tex.image.height;
    const s = settings.photoScale;
    if (aspect > 1) {
        photoMesh.scale.set(s, s / aspect, 1);
    } else {
        photoMesh.scale.set(s * aspect, s, 1);
    }
});

// --- GLASS OBJECT ---
const glassMat = new THREE.MeshPhysicalMaterial({
    color: 0xffffff,
    transmission: 1.0,
    thickness: 1.0,
    ior: 1.5,
    roughness: 0.0,
    metalness: 0.1,
    transparent: true,
    envMapIntensity: 2.2 
});

const glassMesh = new THREE.Mesh(new RoundedBoxGeometry(2.1, 2.1, 0.8, 32, 0.2), glassMat);
group.add(glassMesh);

// --- ANIMATION LOOP ---
const clock = new THREE.Clock();

function animate() {
    requestAnimationFrame(animate);
    const time = clock.getElapsedTime();

    group.rotation.y += 0.005 * settings.rotationSpeed; 
    group.rotation.x = Math.sin(time * 0.4) * 0.1;
    
    arcBlue.intensity = 6 + Math.sin(time * 2) * 2;

    controls.update();
    renderer.render(scene, camera);
}

animate();

window.onresize = () => {
    camera.aspect = window.innerWidth / window.innerHeight;
    camera.updateProjectionMatrix();
    renderer.setSize(window.innerWidth, window.innerHeight);
};
WebGLで実装した、ガラス越しに写真が浮かんで見える表現デモ。
反射と屈折を利用し、写真を立体的なオブジェクトとして見せています。

JSで作るマウスに反応して揺らぐリキッドグラデーション

<h1 class="heading">Liquid Gradient</h1>
<div class="custom-cursor" id="customCursor"></div>
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}
body {
    overflow: hidden;
    font-family: sans-serif;
    background-color: #050000;
    cursor: none;
    /* デフォルトのカーソルを非表示 */
}
#webGLApp {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    z-index: 1;
}
.heading {
    position: fixed;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    z-index: 10;
    color: white;
    text-align: center;
    white-space: nowrap;
    pointer-events: none;
    font-family: "Syne", sans-serif;
    font-size: clamp(3rem, 8vw, 8rem);
    font-weight: 700;
    letter-spacing: -0.02em;
    line-height: 1;
    text-shadow: 0 10px 50px rgba(0, 0, 0, 0.6);
    opacity: 0.8;
}
/* カスタムカーソル:ただの丸に変更 */
.custom-cursor {
    position: fixed;
    width: 30px;
    height: 30px;
    border: 1.5px solid rgba(255, 255, 255, 0.8);
    border-radius: 50%;
    pointer-events: none;
    z-index: 1000;
    transform: translate(-50%, -50%);
    transition: width 0.2s ease, height 0.2s ease;
    will-change: transform;
}
class TouchTexture {
    constructor() {
        this.size = 64;
        this.width = this.height = this.size;
        this.maxAge = 64;
        this.radius = 0.25 * this.size;
        this.speed = 1 / this.maxAge;
        this.trail = [];
        this.last = null;
        this.initTexture();
    }
    initTexture() {
        this.canvas = document.createElement("canvas");
        this.canvas.width = this.width;
        this.canvas.height = this.height;
        this.ctx = this.canvas.getContext("2d");
        this.ctx.fillStyle = "black";
        this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
        this.texture = new THREE.Texture(this.canvas);
    }
    update() {
        this.ctx.fillStyle = "black";
        this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
        for (let i = this.trail.length - 1; i >= 0; i--) {
            const point = this.trail[i];
            let f = point.force * this.speed * (1 - point.age / this.maxAge);
            point.x += point.vx * f;
            point.y += point.vy * f;
            point.age++;
            if (point.age > this.maxAge) this.trail.splice(i, 1);
            else this.drawPoint(point);
        }
        this.texture.needsUpdate = true;
    }
    addTouch(point) {
        let force = 0, vx = 0, vy = 0;
        if (this.last) {
            const dx = point.x - this.last.x, dy = point.y - this.last.y;
            if (dx === 0 && dy === 0) return;
            const dd = dx * dx + dy * dy;
            let d = Math.sqrt(dd);
            vx = dx / d; vy = dy / d;
            force = Math.min(dd * 20000, 2.0);
        }
        this.last = { x: point.x, y: point.y };
        this.trail.push({ x: point.x, y: point.y, age: 0, force, vx, vy });
    }
    drawPoint(point) {
        const pos = { x: point.x * this.width, y: (1 - point.y) * this.height };
        let intensity = point.age < this.maxAge * 0.3 ? 
            Math.sin((point.age / (this.maxAge * 0.3)) * (Math.PI / 2)) : 
            -(1 - (point.age - this.maxAge * 0.3) / (this.maxAge * 0.7)) * ((1 - (point.age - this.maxAge * 0.3) / (this.maxAge * 0.7)) - 2);
        intensity *= point.force;
        let color = `${((point.vx + 1) / 2) * 255}, ${((point.vy + 1) / 2) * 255}, ${intensity * 255}`;
        let offset = this.size * 5;
        this.ctx.shadowOffsetX = this.ctx.shadowOffsetY = offset;
        this.ctx.shadowBlur = this.radius;
        this.ctx.shadowColor = `rgba(${color},${0.2 * intensity})`;
        this.ctx.beginPath();
        this.ctx.fillStyle = "rgba(255,0,0,1)";
        this.ctx.arc(pos.x - offset, pos.y - offset, this.radius, 0, Math.PI * 2);
        this.ctx.fill();
    }
}

class GradientBackground {
    constructor(sceneManager) {
        this.sceneManager = sceneManager;
        this.uniforms = {
            uTime: { value: 0 },
            uResolution: { value: new THREE.Vector2(window.innerWidth, window.innerHeight) },
            uColor1: { value: new THREE.Vector3(0.6, 0, 0) },
            uColor2: { value: new THREE.Vector3(0.4, 0, 0) },
            uColor3: { value: new THREE.Vector3(0.5, 0.05, 0) },
            uSpeed: { value: 0.6 },
            uIntensity: { value: 1.1 },
            uTouchTexture: { value: null },
            uGrainIntensity: { value: 0.04 },
            uDarkBase: { value: new THREE.Vector3(0.01, 0, 0) },
            uGradientSize: { value: 0.65 },
            uGradientCount: { value: 8.0 }
        };
    }
    init() {
        const vs = this.sceneManager.getViewSize();
        const material = new THREE.ShaderMaterial({
            uniforms: this.uniforms,
            vertexShader: `varying vec2 vUv; void main() { gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); vUv = uv; }`,
            fragmentShader: `
                uniform float uTime; uniform vec3 uColor1, uColor2, uColor3, uDarkBase;
                uniform float uSpeed, uIntensity, uGrainIntensity, uGradientSize, uGradientCount;
                uniform sampler2D uTouchTexture; varying vec2 vUv;
                float grain(vec2 uv, float t) { return (fract(sin(dot(uv + t, vec2(12.9898, 78.233))) * 43758.5453) * 2.0 - 1.0); }
                void main() {
                    vec2 uv = vUv;
                    vec4 touch = texture2D(uTouchTexture, uv);
                    uv -= (touch.rg * 2.0 - 1.0) * 0.35 * touch.b;
                    vec3 color = vec3(0.0);
                    for(int i=0; i<10; i++) {
                        if(float(i) >= uGradientCount) break;
                        float fi = float(i);
                        vec2 center = vec2(0.5 + sin(uTime * uSpeed * (0.15 + fi*0.02)) * 0.45, 0.5 + cos(uTime * uSpeed * (0.2 + fi*0.02)) * 0.45);
                        float influence = 1.0 - smoothstep(0.0, uGradientSize, length(uv - center));
                        vec3 c = (mod(fi, 3.0) == 0.0) ? uColor1 : (mod(fi, 3.0) == 1.0) ? uColor2 : uColor3;
                        color += c * influence * (0.8 + 0.2 * sin(uTime + fi));
                    }
                    color *= uIntensity;
                    color = mix(uDarkBase, color, smoothstep(0.0, 0.6, length(color)));
                    color += grain(vUv, uTime) * uGrainIntensity;
                    gl_FragColor = vec4(clamp(color, 0.0, 1.0), 1.0);
                }
            `
        });
        this.mesh = new THREE.Mesh(new THREE.PlaneGeometry(vs.width, vs.height), material);
        this.sceneManager.scene.add(this.mesh);
    }
    hslToRgb(h, s, l) {
        let r, g, b;
        const hue2rgb = (p, q, t) => {
            if (t < 0) t += 1; if (t > 1) t -= 1;
            if (t < 1/6) return p + (q - p) * 6 * t;
            if (t < 1/2) return q;
            if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
            return p;
        };
        const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
        const p = 2 * l - q;
        r = hue2rgb(p, q, h + 1/3); g = hue2rgb(p, q, h); b = hue2rgb(p, q, h - 1/3);
        return new THREE.Vector3(r, g, b);
    }
    update(delta) {
        this.uniforms.uTime.value += delta;
        const time = this.uniforms.uTime.value;
        
        // 赤色から変化し始めるまでの時間を短縮 (5秒)
        const holdDuration = 5.0;
        // 色の変化速度を少し上げる (0.01)
        const transitionSpeed = 0.01; 
        const hueShift = Math.max(0, (time - holdDuration)) * transitionSpeed;

        this.uniforms.uColor1.value.copy(this.hslToRgb((hueShift) % 1.0, 0.85, 0.35));
        this.uniforms.uColor2.value.copy(this.hslToRgb((hueShift + 0.03) % 1.0, 0.75, 0.25));
        this.uniforms.uColor3.value.copy(this.hslToRgb((hueShift - 0.03) % 1.0, 0.85, 0.3));
    }
}

class App {
    constructor() {
        this.renderer = new THREE.WebGLRenderer({ antialias: true });
        this.renderer.setSize(window.innerWidth, window.innerHeight);
        this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
        document.body.appendChild(this.renderer.domElement);
        this.camera = new THREE.PerspectiveCamera(45, window.innerWidth/window.innerHeight, 0.1, 1000);
        this.camera.position.z = 50;
        this.scene = new THREE.Scene();
        this.clock = new THREE.Clock();
        this.touchTexture = new TouchTexture();
        this.gradientBackground = new GradientBackground(this);
        this.gradientBackground.uniforms.uTouchTexture.value = this.touchTexture.texture;
        this.init();
    }
    init() {
        this.gradientBackground.init();
        this.tick();
        window.addEventListener("resize", () => {
            this.camera.aspect = window.innerWidth / window.innerHeight;
            this.camera.updateProjectionMatrix();
            this.renderer.setSize(window.innerWidth, window.innerHeight);
            const vs = this.getViewSize();
            this.gradientBackground.mesh.geometry.dispose();
            this.gradientBackground.mesh.geometry = new THREE.PlaneGeometry(vs.width, vs.height);
        });
        window.addEventListener("mousemove", (e) => this.touchTexture.addTouch({ x: e.clientX/window.innerWidth, y: 1 - e.clientY/window.innerHeight }));
    }
    getViewSize() {
        const h = Math.abs(this.camera.position.z * Math.tan((this.camera.fov * Math.PI / 180)/2) * 2);
        return { width: h * this.camera.aspect, height: h };
    }
    tick() {
        const delta = Math.min(this.clock.getDelta(), 0.1);
        this.touchTexture.update();
        this.gradientBackground.update(delta);
        this.renderer.render(this.scene, this.camera);
        requestAnimationFrame(() => this.tick());
    }
}
new App();

const cursor = document.getElementById("customCursor");
document.addEventListener("mousemove", (e) => {
    cursor.style.left = e.clientX + "px";
    cursor.style.top = e.clientY + "px";
});
https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js
WebGLで実装した、マウス操作に反応して揺らぐリキッドグラデーション表現。
シェーダと動的テクスチャを使い、液体のような歪みと色の変化を描画しています。

CSSとJSで作る、クリックでつかんで振って合計が出る3Dサイコロ

<div id="kumonosu-ui-container">
  <div class="kumonosu-top-bar">
    <p class="kumonosu-bar-text">Dice Magnitude</p>
    <label class="kumonosu-select">
      <select id="kumonosu-dice-count">
        <option value="1">01</option>
        <option value="2">02</option>
        <option value="3" selected>03</option>
        <option value="4">04</option>
        <option value="5">05</option>
      </select>
    </label>
  </div>

  <div id="kumonosu-result-board">
    <span id="kumonosu-total-score">0</span>
    <span class="kumonosu-sub-text" id="kumonosu-detail-score"></span>
  </div>
</div>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;700&family=Syncopate:wght@700&display=swap');
* {
	box-sizing: border-box;
	font-family: "Inter", sans-serif;
	margin: 0;
	padding: 0;
}
body {
	margin: 0;
	overflow: hidden;
	background-color: #0a0a0a;
	user-select: none;
}
#kumonosu-ui-container {
	position: absolute;
	top: 0;
	left: 0;
	width: 100%;
	height: 100%;
	pointer-events: none;
	display: flex;
	flex-direction: column;
	align-items: center;
	z-index: 10;
}
.kumonosu-top-bar {
	margin-top: 30px;
	background: rgba(255, 255, 255, 0.03);
	backdrop-filter: blur(10px);
	-webkit-backdrop-filter: blur(10px);
	padding: 10px 25px;
	border-radius: 4px;
	border: 1px solid rgba(255, 255, 255, 0.1);
	pointer-events: auto;
	display: flex;
	gap: 20px;
	align-items: center;
	box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
}
.kumonosu-bar-text {
	color: rgba(255, 255, 255, 0.5);
	font-size: 11px;
	letter-spacing: 3px;
	text-transform: uppercase;
	font-weight: 700;
}
#kumonosu-dice-count {
	padding: 6px 12px;
	font-size: 14px;
	border-radius: 2px;
	border: 1px solid rgba(255, 255, 255, 0.2);
	background: transparent;
	color: #00e5ff;
	cursor: pointer;
	outline: none;
	appearance: none;
	-webkit-appearance: none;
	font-weight: 700;
	transition: all 0.2s ease;
	text-align: center;
}
#kumonosu-dice-count:hover {
	border-color: #00e5ff;
	background: rgba(0, 229, 255, 0.1);
}
#kumonosu-result-board {
	margin-top: 60px;
	color: #ffffff;
	font-family: "Syncopate", sans-serif;
	font-size: 80px;
	opacity: 0;
	transition: all 0.5s cubic-bezier(0.16, 1, 0.3, 1);
	text-align: center;
	text-shadow: 0 0 20px rgba(0, 229, 255, 0.3);
	transform: translateY(20px);
}
#kumonosu-result-board.kumonosu-show {
	opacity: 1;
	transform: translateY(0);
}
.kumonosu-sub-text {
	font-family: "Inter", sans-serif;
	font-size: 14px;
	font-weight: 300;
	color: rgba(255, 255, 255, 0.4);
	display: block;
	margin-top: 10px;
	letter-spacing: 4px;
}
canvas {
	display: block;
}
// 直接URLを指定することで単一のスクリプトタグに集約
import * as THREE from "https://esm.sh/three@0.160.0";
import {
	RoundedBoxGeometry
} from "https://esm.sh/three@0.160.0/examples/jsm/geometries/RoundedBoxGeometry.js";
import * as CANNON from "https://esm.sh/cannon-es@0.20.0";
let scene, camera, renderer, world;
let diceObjects = [];
let isHolding = false;
let needsResultCheck = false;
let mouse = new THREE.Vector2();
let raycaster = new THREE.Raycaster();
const FRUSTUM_SIZE = 23;
let dragPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -15);
const uiResult = document.getElementById("kumonosu-result-board");
const uiTotal = document.getElementById("kumonosu-total-score");
const uiDetail = document.getElementById("kumonosu-detail-score");
const palette = ["#1a1a1a", "#222222", "#2a2a2a", "#333333"];
const commonColors = {
	dots: "#ffffff",
	outline: "#444444",
	shadow: "#000000"
};
init();
animate();

function init() {
	scene = new THREE.Scene();
	scene.background = new THREE.Color("#0a0a0a");
	const aspect = window.innerWidth / window.innerHeight;
	camera = new THREE.OrthographicCamera(
		(FRUSTUM_SIZE * aspect) / -2,
		(FRUSTUM_SIZE * aspect) / 2, FRUSTUM_SIZE / 2, FRUSTUM_SIZE / -2, 1, 1000);
	camera.position.set(50, 50, 50);
	camera.lookAt(0, 0, 0);
	renderer = new THREE.WebGLRenderer({
		antialias: true,
		alpha: true
	});
	renderer.setSize(window.innerWidth, window.innerHeight);
	renderer.setPixelRatio(window.devicePixelRatio);
	renderer.domElement.style.touchAction = 'none';
	document.body.appendChild(renderer.domElement);
	world = new CANNON.World();
	world.gravity.set(0, -40, 0);
	world.broadphase = new CANNON.NaiveBroadphase();
	world.solver.iterations = 20;
	world.allowSleep = true;
	const wallMat = new CANNON.Material();
	const diceMat = new CANNON.Material();
	world.addContactMaterial(new CANNON.ContactMaterial(wallMat, diceMat, {
		friction: 0.1,
		restitution: 0.5
	}));
	createPhysicsWalls(wallMat);
	updateDiceCount(3);
	window.addEventListener("resize", onWindowResize);
	window.addEventListener("mousedown", onInputStart);
	window.addEventListener("mousemove", onInputMove);
	window.addEventListener("mouseup", onInputEnd);
	window.addEventListener("touchstart", onInputStart, {
		passive: false
	});
	window.addEventListener("touchmove", onInputMove, {
		passive: false
	});
	window.addEventListener("touchend", onInputEnd);
	const countSelect = document.getElementById("kumonosu-dice-count");
	if (countSelect) {
		countSelect.addEventListener("change", (e) => {
			updateDiceCount(parseInt(e.target.value));
		});
	}
}

function updateMousePosition(e) {
	let x, y;
	if (e.changedTouches) {
		x = e.changedTouches[0].clientX;
		y = e.changedTouches[0].clientY;
	} else {
		x = e.clientX;
		y = e.clientY;
	}
	mouse.x = (x / window.innerWidth) * 2 - 1;
	mouse.y = -(y / window.innerHeight) * 2 + 1;
}

function onInputStart(e) {
	if (e.target.tagName === "SELECT" || e.target.closest(".kumonosu-top-bar")) return;
	if (e.cancelable) e.preventDefault();
	isHolding = true;
	needsResultCheck = false;
	if (uiResult) uiResult.classList.remove("kumonosu-show");
	updateMousePosition(e);
	diceObjects.forEach(obj => {
		obj.body.wakeUp();
		obj.spinOffset = Math.random() * 100;
		obj.isReturning = false;
	});
}

function onInputMove(e) {
	if (!isHolding) return;
	if (e.cancelable) e.preventDefault();
	updateMousePosition(e);
}

function onInputEnd() {
	if (!isHolding) return;
	isHolding = false;
	releaseDice();
}

function createPhysicsWalls(material) {
	const floorBody = new CANNON.Body({
		mass: 0,
		material: material
	});
	floorBody.addShape(new CANNON.Plane());
	floorBody.quaternion.setFromAxisAngle(new CANNON.Vec3(1, 0, 0), -Math.PI / 2);
	world.addBody(floorBody);
	const wallDistance = 12;
	const createWall = (x, z, rot) => {
		const body = new CANNON.Body({
			mass: 0,
			material: material
		});
		body.addShape(new CANNON.Plane());
		body.position.set(x, 0, z);
		body.quaternion.setFromAxisAngle(new CANNON.Vec3(0, 1, 0), rot);
		world.addBody(body);
	};
	createWall(wallDistance, 0, -Math.PI / 2);
	createWall(-wallDistance, 0, Math.PI / 2);
	createWall(0, -wallDistance, 0);
	createWall(0, wallDistance, Math.PI);
}

function createVectorDiceTexture(number, colorHex) {
	const size = 256;
	const canvas = document.createElement("canvas");
	canvas.width = size;
	canvas.height = size;
	const ctx = canvas.getContext("2d");
	ctx.fillStyle = colorHex;
	ctx.fillRect(0, 0, size, size);
	ctx.fillStyle = "#ffffff";
	const dotSize = size / 6.5;
	const center = size / 2;
	const q1 = size / 4;
	const q3 = (size * 3) / 4;

	function drawDot(x, y) {
		ctx.beginPath();
		ctx.arc(x, y, dotSize / 2, 0, Math.PI * 2);
		ctx.fill();
	}
	if (number === 1) drawDot(center, center);
	else if (number === 2) {
		drawDot(q1, q1);
		drawDot(q3, q3);
	} else if (number === 3) {
		drawDot(q1, q1);
		drawDot(center, center);
		drawDot(q3, q3);
	} else if (number === 4) {
		drawDot(q1, q1);
		drawDot(q3, q1);
		drawDot(q1, q3);
		drawDot(q3, q3);
	} else if (number === 5) {
		drawDot(q1, q1);
		drawDot(q3, q1);
		drawDot(center, center);
		drawDot(q1, q3);
		drawDot(q3, q3);
	} else if (number === 6) {
		drawDot(q1, q1);
		drawDot(q3, q1);
		drawDot(q1, center);
		drawDot(q3, center);
		drawDot(q1, q3);
		drawDot(q3, q3);
	}
	const tex = new THREE.CanvasTexture(canvas);
	tex.anisotropy = renderer.capabilities.getMaxAnisotropy();
	return tex;
}

function updateDiceCount(count) {
	diceObjects.forEach((obj) => {
		scene.remove(obj.mesh);
		scene.remove(obj.outline);
		scene.remove(obj.shadow);
		world.removeBody(obj.body);
	});
	diceObjects = [];
	if (uiResult) uiResult.classList.remove("kumonosu-show");
	const boxSize = 2.5;
	const geometry = new RoundedBoxGeometry(boxSize, boxSize, boxSize, 3, 0.25);
	const outlineGeo = geometry.clone();
	const shadowGeo = new THREE.CircleGeometry(boxSize * 0.6, 32);
	const shape = new CANNON.Box(new CANNON.Vec3(boxSize / 2, boxSize / 2, boxSize / 2));
	const outlineMat = new THREE.MeshBasicMaterial({
		color: commonColors.outline,
		side: THREE.BackSide
	});
	const shadowMat = new THREE.MeshBasicMaterial({
		color: commonColors.shadow,
		transparent: true,
		opacity: 0.4
	});
	for (let i = 0; i < count; i++) {
		let diceColor;
		if (i === 0) {
			diceColor = "#9b0000";
		} else if (i === 1) {
			diceColor = "#002a7a";
		} else {
			diceColor = palette[Math.floor(Math.random() * palette.length)];
		}
		const diceMaterials = [];
		for (let j = 1; j <= 6; j++) {
			diceMaterials.push(new THREE.MeshBasicMaterial({
				map: createVectorDiceTexture(j, diceColor)
			}));
		}
		const matArray = [
			diceMaterials[0], diceMaterials[5], diceMaterials[1],
			diceMaterials[4], diceMaterials[2], diceMaterials[3]
		];
		const mesh = new THREE.Mesh(geometry, matArray);
		scene.add(mesh);
		const outline = new THREE.Mesh(outlineGeo, outlineMat);
		outline.position.copy(mesh.position);
		outline.scale.setScalar(1.02);
		scene.add(outline);
		const shadow = new THREE.Mesh(shadowGeo, shadowMat);
		shadow.rotation.x = -Math.PI / 2;
		shadow.position.y = 0.01;
		scene.add(shadow);
		const startX = (i - (count - 1) / 2) * 3.5;
		const body = new CANNON.Body({
			mass: 5,
			shape: shape,
			position: new CANNON.Vec3(startX, boxSize, 0),
			sleepSpeedLimit: 0.5
		});
		body.quaternion.setFromEuler(Math.random() * Math.PI, Math.random() * Math.PI, Math.random() * Math.PI);
		world.addBody(body);
		diceObjects.push({
			mesh,
			outline,
			shadow,
			body,
			spinOffset: 0,
			isReturning: false
		});
	}
}

function releaseDice() {
	const SAFE_LIMIT = 9;
	diceObjects.forEach((obj) => {
		const {
			body
		} = obj;
		const isOutside = Math.abs(body.position.x) > SAFE_LIMIT || Math.abs(body.position.z) > SAFE_LIMIT;
		if (isOutside) {
			obj.isReturning = true;
		} else {
			body.wakeUp();
			applyThrowForce(body);
		}
	});
	setTimeout(() => {
		needsResultCheck = true;
	}, 500);
}

function applyThrowForce(body) {
	const xDist = -body.position.x;
	const zDist = -body.position.z;
	body.velocity.set(xDist * 1.8 + (Math.random() - 0.5) * 20, -20 - Math.random() * 10, zDist * 1.8 + (Math.random() - 0.5) * 20);
	body.angularVelocity.set(
		(Math.random() - 0.5) * 40,
		(Math.random() - 0.5) * 40,
		(Math.random() - 0.5) * 40);
}

function calculateResult() {
	let total = 0;
	let details = [];
	const faceNormals = [
		new THREE.Vector3(1, 0, 0), new THREE.Vector3(-1, 0, 0),
		new THREE.Vector3(0, 1, 0), new THREE.Vector3(0, -1, 0),
		new THREE.Vector3(0, 0, 1), new THREE.Vector3(0, 0, -1)
	];
	const faceValues = [1, 6, 2, 5, 3, 4];
	diceObjects.forEach(({
		mesh
	}) => {
		let maxDot = -Infinity;
		let resultValue = 1;
		faceNormals.forEach((normal, index) => {
			const worldNormal = normal.clone().applyQuaternion(mesh.quaternion);
			if (worldNormal.y > maxDot) {
				maxDot = worldNormal.y;
				resultValue = faceValues[index];
			}
		});
		total += resultValue;
		details.push(resultValue);
	});
	if (uiTotal) uiTotal.innerText = total;
	if (uiDetail) uiDetail.innerText = details.length > 1 ? `LOG: [ ${details.join(" | ")} ]` : "";
	if (uiResult) uiResult.classList.add("kumonosu-show");
	needsResultCheck = false;
}

function animate() {
	requestAnimationFrame(animate);
	if (isHolding) {
		raycaster.setFromCamera(mouse, camera);
		const targetPoint = new THREE.Vector3();
		const intersect = raycaster.ray.intersectPlane(dragPlane, targetPoint);
		if (intersect) {
			const time = performance.now() * 0.01;
			diceObjects.forEach((obj, i) => {
				const offsetX = Math.sin(time + i) * 1.2;
				const offsetZ = Math.cos(time + i * 2) * 1.2;
				obj.body.position.x += (targetPoint.x + offsetX - obj.body.position.x) * 0.2;
				obj.body.position.y += (15 - obj.body.position.y) * 0.2;
				obj.body.position.z += (targetPoint.z + offsetZ - obj.body.position.z) * 0.2;
				obj.body.quaternion.setFromEuler(time * 3 + obj.spinOffset, time * 2, time * 1.5);
				obj.body.velocity.set(0, 0, 0);
				obj.body.angularVelocity.set(0, 0, 0);
				obj.isReturning = false;
			});
		}
	} else {
		const time = performance.now() * 0.01;
		diceObjects.forEach((obj) => {
			if (obj.isReturning) {
				obj.body.position.x += (0 - obj.body.position.x) * 0.15;
				obj.body.position.z += (0 - obj.body.position.z) * 0.15;
				obj.body.position.y += (12 - obj.body.position.y) * 0.1;
				obj.body.quaternion.setFromEuler(time * 5, time * 5, 0);
				obj.body.velocity.set(0, 0, 0);
				obj.body.angularVelocity.set(0, 0, 0);
				if (Math.abs(obj.body.position.x) < 9 && Math.abs(obj.body.position.z) < 9) {
					obj.isReturning = false;
					obj.body.wakeUp();
					applyThrowForce(obj.body);
				}
			}
		});
		world.step(1 / 60);
	}
	for (let i = 0; i < diceObjects.length; i++) {
		const {
			mesh,
			outline,
			shadow,
			body
		} = diceObjects[i];
		mesh.position.copy(body.position);
		mesh.quaternion.copy(body.quaternion);
		outline.position.copy(mesh.position);
		outline.quaternion.copy(mesh.quaternion);
		shadow.position.x = body.position.x;
		shadow.position.z = body.position.z;
		const height = Math.max(0, body.position.y - 1);
		const scale = Math.max(0.4, 1 - height * 0.05);
		const opacity = Math.max(0, 0.4 - height * 0.02);
		shadow.scale.setScalar(scale);
		shadow.material.opacity = opacity;
	}
	if (needsResultCheck) {
		let allStopped = true;
		for (let o of diceObjects) {
			if (o.isReturning) {
				allStopped = false;
				break;
			}
			if (o.body.velocity.lengthSquared() > 0.1 || o.body.angularVelocity.lengthSquared() > 0.1) {
				allStopped = false;
				break;
			}
		}
		if (allStopped) calculateResult();
	}
	renderer.render(scene, camera);
}

function onWindowResize() {
	const aspect = window.innerWidth / window.innerHeight;
	camera.left = (-FRUSTUM_SIZE * aspect) / 2;
	camera.right = (FRUSTUM_SIZE * aspect) / 2;
	camera.top = FRUSTUM_SIZE / 2;
	camera.bottom = -FRUSTUM_SIZE / 2;
	camera.updateProjectionMatrix();
	renderer.setSize(window.innerWidth, window.innerHeight);
}
ドラッグで宙に集めて振り、リリースで投げて転がす3Dサイコロ。物理演算で動きを作り、停止後に出目を判定して合計と内訳を表示します。