AIプロンプト有!Webカメラに手をかざすと動くパーティクルの実装!

アニメーション

AIプロンプト有!Webカメラに手をかざすと動くパーティクルの実装!

投稿日2026/01/15

更新日2026/1/15

Webカメラの前に手をかざすだけで、数万の粒子があなたの動きに共鳴します。

本記事では、GoogleのAI技術「MediaPipe Hands」と、強力な3Dライブラリ「Three.js」を融合させた、近未来的なインタラクティブ・アートをご紹介します。

今回は、AIへの指示に使用したプロンプトも公開。最新のAI技術を使って、魔法のようなユーザー体験をWebサイトに実装する方法を詳しく解説します。
※カメラの切り忘れにご注意ください。

Preview プレビュー

Code コード

<div id="kumonosu-loading">
        <div>Initialize AI Camera...</div>
        <div class="kumonosu-loading-sub">Please allow camera access</div>
    </div>

    <!-- UI Panel -->
    <div id="kumonosu-ui-panel">
        <h2>Particle Controller</h2>
        
        <div class="kumonosu-control-group">
            <label>Shape Template</label>
            <div class="kumonosu-shape-grid">
                <button class="kumonosu-shape-btn kumonosu-active" data-shape="heart">Heart</button>
                <button class="kumonosu-shape-btn" data-shape="flower">Flower</button>
                <button class="kumonosu-shape-btn" data-shape="saturn">Saturn</button>
                <button class="kumonosu-shape-btn" data-shape="spiral">Spiral</button>
                <button class="kumonosu-shape-btn" data-shape="fireworks" style="grid-column: span 2;">Fireworks</button>
            </div>
        </div>

        <div class="kumonosu-control-group">
            <label>Particle Color</label>
            <input type="color" id="kumonosu-color-picker" value="#4facfe">
        </div>

        <div class="kumonosu-control-group">
            <label>Hand Interaction</label>
            <div style="font-size: 11px; color: #888; line-height: 1.4;">
                Open Hand: Expand / Explode<br>
                Closed Fist: Contract / Tension
            </div>
        </div>
    </div>

    <div id="kumonosu-status">Waiting for camera...</div>

    <!-- Canvas Container -->
    <div id="kumonosu-canvas-container"></div>
    <video id="kumonosu-input-video" playsinline></video>
#kumonosu-canvas-container {
	position: absolute;
	top: 0;
	left: 0;
	width: 100%;
	height: 100%;
	z-index: 1;
}

/* Hidden video element for MediaPipe processing */
#kumonosu-input-video {
	display: none;
}

/* Modern UI Panel */
#kumonosu-ui-panel {
	position: absolute;
	top: 20px;
	right: 20px;
	width: 280px;
	background: rgba(20, 20, 25, 0.7);
	backdrop-filter: blur(10px);
	-webkit-backdrop-filter: blur(10px);
	padding: 20px;
	border-radius: 16px;
	border: 1px solid rgba(255, 255, 255, 0.1);
	z-index: 10;
	box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
	transition: opacity 0.3s;
}

h2 {
	margin: 0 0 15px 0;
	font-size: 16px;
	font-weight: 600;
	letter-spacing: 1px;
	text-transform: uppercase;
	color: #ccc;
	border-bottom: 1px solid rgba(255, 255, 255, 0.1);
	padding-bottom: 10px;
}

.kumonosu-control-group {
	margin-bottom: 20px;
}

label {
	display: block;
	margin-bottom: 8px;
	font-size: 12px;
	color: #aaa;
}

/* Shape Buttons */
.kumonosu-shape-grid {
	display: grid;
	grid-template-columns: 1fr 1fr;
	gap: 8px;
}

button {
	background: rgba(255, 255, 255, 0.05);
	border: 1px solid rgba(255, 255, 255, 0.1);
	color: white;
	padding: 10px;
	border-radius: 8px;
	cursor: pointer;
	transition: all 0.2s ease;
	font-size: 12px;
}

button:hover {
	background: rgba(255, 255, 255, 0.15);
	transform: translateY(-2px);
}

button.kumonosu-active {
	background: #4facfe;
	border-color: #4facfe;
	color: white;
	box-shadow: 0 0 15px rgba(79, 172, 254, 0.4);
}

/* Color Picker */
input[type="color"] {
	-webkit-appearance: none;
	border: none;
	width: 100%;
	height: 40px;
	border-radius: 8px;
	cursor: pointer;
	background: none;
}

input[type="color"]::-webkit-color-swatch-wrapper {
	padding: 0;
}

input[type="color"]::-webkit-color-swatch {
	border: 1px solid rgba(255, 255, 255, 0.2);
	border-radius: 8px;
}

/* Status Display */
#kumonosu-status {
	position: absolute;
	bottom: 20px;
	left: 20px;
	z-index: 10;
	font-size: 14px;
	color: #888;
	pointer-events: none;
	background: rgba(0, 0, 0, 0.5);
	padding: 8px 12px;
	border-radius: 20px;
}

#kumonosu-loading {
	position: absolute;
	top: 50%;
	left: 50%;
	transform: translate(-50%, -50%);
	z-index: 20;
	font-size: 24px;
	font-weight: bold;
	color: #4facfe;
	text-shadow: 0 0 20px rgba(79, 172, 254, 0.8);
	pointer-events: none;
	text-align: center;
}

.kumonosu-loading-sub {
	font-size: 14px;
	color: #888;
	margin-top: 10px;
	font-weight: normal;
}
        import * as THREE from 'https://cdn.skypack.dev/three@0.136.0';
        import { OrbitControls } from 'https://cdn.skypack.dev/three@0.136.0/examples/jsm/controls/OrbitControls.js';

        // --- Config ---
        const CONFIG = {
            particleCount: 15000,
            particleSize: 0.15,
            baseColor: new THREE.Color('#4facfe'),
            handInfluence: 0, // 0 (Fist) to 1 (Open)
            currentShape: 'heart'
        };

        // --- Three.js Initialization ---
        const container = document.getElementById('kumonosu-canvas-container');
        const scene = new THREE.Scene();
        scene.fog = new THREE.FogExp2(0x050505, 0.02);

        const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 100);
        camera.position.z = 20;
        camera.position.y = 5;

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

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

        // --- Particle System ---
        const geometry = new THREE.BufferGeometry();
        const positions = new Float32Array(CONFIG.particleCount * 3);
        const targetPositions = new Float32Array(CONFIG.particleCount * 3);
        
        for (let i = 0; i < CONFIG.particleCount * 3; i++) {
            positions[i] = (Math.random() - 0.5) * 50;
            targetPositions[i] = positions[i];
        }

        geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));

        const material = new THREE.PointsMaterial({
            color: CONFIG.baseColor,
            size: CONFIG.particleSize,
            sizeAttenuation: true,
            transparent: true,
            opacity: 0.8,
            blending: THREE.AdditiveBlending,
            depthWrite: false
        });

        const sprite = new THREE.TextureLoader().load('https://threejs.org/examples/textures/sprites/disc.png');
        material.map = sprite;

        const particleSystem = new THREE.Points(geometry, material);
        scene.add(particleSystem);

        // --- Shape Generation Functions ---
        function getPointOnSphere(r) {
            const u = Math.random();
            const v = Math.random();
            const theta = 2 * Math.PI * u;
            const phi = Math.acos(2 * v - 1);
            return {
                x: r * Math.sin(phi) * Math.cos(theta),
                y: r * Math.sin(phi) * Math.sin(theta),
                z: r * Math.cos(phi)
            };
        }

        function generateShape(shapeName) {
            const positions = [];
            const count = CONFIG.particleCount;

            for (let i = 0; i < count; i++) {
                let x, y, z;
                
                if (shapeName === 'heart') {
                    const phi = Math.acos(2 * Math.random() - 1);
                    const theta = Math.random() * Math.PI * 2;
                    x = 16 * Math.pow(Math.sin(theta), 3) * Math.sin(phi);
                    y = (13 * Math.cos(theta) - 5 * Math.cos(2*theta) - 2 * Math.cos(3*theta) - Math.cos(4*theta)) * Math.sin(phi);
                    z = 8 * Math.cos(phi); 
                    x *= 0.5; y *= 0.5; z *= 0.5;
                } else if (shapeName === 'flower') {
                    const angle = i * 137.5 * (Math.PI / 180);
                    const r = 0.3 * Math.sqrt(i);
                    x = r * Math.cos(angle);
                    y = (Math.random() - 0.5) * 5 + Math.sin(r * 0.5) * 5;
                    z = r * Math.sin(angle);
                } else if (shapeName === 'saturn') {
                    if (i < count * 0.3) {
                        const p = getPointOnSphere(4);
                        x = p.x; y = p.y; z = p.z;
                    } else {
                        const angle = Math.random() * Math.PI * 2;
                        const dist = 6 + Math.random() * 4;
                        x = Math.cos(angle) * dist;
                        z = Math.sin(angle) * dist;
                        y = (Math.random() - 0.5) * 0.5; 
                        const tilt = 0.4;
                        const tempY = y * Math.cos(tilt) - z * Math.sin(tilt);
                        const tempZ = y * Math.sin(tilt) + z * Math.cos(tilt);
                        y = tempY; z = tempZ;
                    }
                } else if (shapeName === 'spiral') {
                    const t = i * 0.1;
                    const r = 3 + Math.sin(t * 0.1);
                    x = r * Math.cos(t * 0.1);
                    y = (i * 0.02) - 10;
                    z = r * Math.sin(t * 0.1);
                } else if (shapeName === 'fireworks') {
                    const p = getPointOnSphere(Math.random() * 20);
                    x = p.x; y = p.y; z = p.z;
                }
                positions.push(x, y, z);
            }
            return positions;
        }

        function updateTargetShape(shapeName) {
            const newPos = generateShape(shapeName);
            for(let i = 0; i < CONFIG.particleCount * 3; i++) {
                targetPositions[i] = newPos[i];
            }
        }

        updateTargetShape('heart');

        // --- MediaPipe Hands Setup ---
        const videoElement = document.getElementById('kumonosu-input-video');
        const statusElement = document.getElementById('kumonosu-status');
        const loadingElement = document.getElementById('kumonosu-loading');

        if (typeof window.Hands === 'undefined' || typeof window.Camera === 'undefined') {
            statusElement.innerText = "Error: MediaPipe libraries failed to load.";
            loadingElement.innerText = "Library Load Error";
        }

        const hands = new window.Hands({locateFile: (file) => {
            return `https://cdn.jsdelivr.net/npm/@mediapipe/hands/${file}`;
        }});

        hands.setOptions({
            maxNumHands: 2,
            modelComplexity: 1,
            minDetectionConfidence: 0.5,
            minTrackingConfidence: 0.5
        });

        hands.onResults(onHandsResults);

        try {
            const cameraUtils = new window.Camera(videoElement, {
                onFrame: async () => {
                    await hands.send({image: videoElement});
                },
                width: 640,
                height: 480
            });
            
            cameraUtils.start()
                .then(() => {
                    loadingElement.style.display = 'none';
                    statusElement.innerText = "Camera active. Show your hand!";
                })
                .catch(err => {
                    loadingElement.innerHTML = "Camera Error<br><span style='font-size:12px; font-weight:normal'>Please allow camera access and reload</span>";
                    console.error("Camera start error:", err);
                });
        } catch (e) {
            console.error("Camera initialization failed", e);
            loadingElement.innerText = "Camera Init Failed";
        }

        function onHandsResults(results) {
            if (results.multiHandLandmarks && results.multiHandLandmarks.length > 0) {
                const landmarks = results.multiHandLandmarks[0];
                const wrist = landmarks[0];
                const tips = [8, 12, 16, 20];
                let totalDist = 0;

                tips.forEach(idx => {
                    const tip = landmarks[idx];
                    const dist = Math.sqrt(Math.pow(tip.x - wrist.x, 2) + Math.pow(tip.y - wrist.y, 2));
                    totalDist += dist;
                });

                const avgDist = totalDist / tips.length;
                const minVal = 0.2;
                const maxVal = 0.5;
                let influence = (avgDist - minVal) / (maxVal - minVal);
                influence = Math.max(0, Math.min(1, influence));
                CONFIG.handInfluence += (influence - CONFIG.handInfluence) * 0.1;
                statusElement.innerText = `Hand detected: ${influence < 0.3 ? "Closed (Tension)" : "Open (Expand)"}`;
            } else {
                CONFIG.handInfluence += (0.5 - CONFIG.handInfluence) * 0.05;
                statusElement.innerText = "No hand detected";
            }
        }

        // --- Event Listeners ---
        document.querySelectorAll('.kumonosu-shape-btn').forEach(btn => {
            btn.addEventListener('click', (e) => {
                document.querySelectorAll('.kumonosu-shape-btn').forEach(b => b.classList.remove('kumonosu-active'));
                e.target.classList.add('kumonosu-active');
                const shape = e.target.dataset.shape;
                CONFIG.currentShape = shape;
                updateTargetShape(shape);
            });
        });

        document.getElementById('kumonosu-color-picker').addEventListener('input', (e) => {
            material.color.set(e.target.value);
        });

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

        // --- Animation Loop ---
        const clock = new THREE.Clock();

        function animate() {
            requestAnimationFrame(animate);
            const delta = clock.getDelta();
            const time = clock.getElapsedTime();
            controls.update();

            const posAttribute = geometry.attributes.position;
            const currentPos = posAttribute.array;
            let targetScale = 0.5 + CONFIG.handInfluence * 1.5;
            if (CONFIG.currentShape === 'fireworks') targetScale = 0.2 + CONFIG.handInfluence * 3.0;

            for (let i = 0; i < CONFIG.particleCount; i++) {
                const idx = i * 3;
                let tx = targetPositions[idx], ty = targetPositions[idx+1], tz = targetPositions[idx+2];
                let cx = currentPos[idx], cy = currentPos[idx+1], cz = currentPos[idx+2];

                const lerpSpeed = 2.0 * delta; 
                const dist = Math.sqrt(tx*tx + ty*ty + tz*tz); 
                let sx = tx * targetScale, sy = ty * targetScale, sz = tz * targetScale;

                if (CONFIG.handInfluence < 0.3) {
                    const jitter = (0.3 - CONFIG.handInfluence) * 0.5;
                    sx += (Math.random() - 0.5) * jitter;
                    sy += (Math.random() - 0.5) * jitter;
                    sz += (Math.random() - 0.5) * jitter;
                }
                
                const breathe = Math.sin(time * 2 + dist * 0.5) * 0.2;
                sx += sx * breathe * 0.1; sy += sy * breathe * 0.1; sz += sz * breathe * 0.1;

                currentPos[idx] += (sx - cx) * lerpSpeed;
                currentPos[idx+1] += (sy - cy) * lerpSpeed;
                currentPos[idx+2] += (sz - cz) * lerpSpeed;
            }

            posAttribute.needsUpdate = true;
            renderer.render(scene, camera);
        }

        animate();
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/camera_utils/camera_utils.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/control_utils/control_utils.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/hands/hands.js" crossorigin="anonymous"></script>

Explanation 詳しい説明

デザインと動きの特徴

このアニメーションは、単なる動画再生ではなく、AIによるリアルタイム推論3Dレンダリングを組み合わせた高度なジェスチャーシステムです。

  • AIによる高精度なハンドトラッキング
    MediaPipe Handsを使用し、Webカメラから21箇所の指の関節をリアルタイムに検出します。手のひらの「開き具合」を計算し、直感的な操作感を実現しています。
  • 15,000個の動的パーティクル
    Three.jsのGPUレンダリング能力を活かし、15,000個もの粒子を同時に制御。加算合成(Additive Blending)による発光エフェクトが、サイバーパンクかつ幻想的な視覚効果を生み出します。
  • 滑らかなモーフィング・アニメーション
    ハート、花、土星、スパイラルといった異なる形状へ、粒子一つ一つが最短距離で移動する「モーフィング」処理を行っています。数式に基づいたスムーズな形状変化は、ユーザーに飽きさせない視覚的快感を与えます。
  • ジェスチャー連動のエフェクト
    手を握ると粒子に「緊張(Contract)」が走り、手を開くと「解放(Expand/Explode)」される。この物理的な感覚に近いインタラクションが、没入感を高めます。

SEO上のメリットと使い時

最新技術の実装は、検索エンジンからの評価(E-E-A-T)やユーザー行動指標に好影響を与える可能性があります。

  • 滞在時間(Dwell Time)の劇的な向上
    ユーザーが自ら操作して遊べるコンテンツは、通常のテキスト記事に比べて滞在時間が圧倒的に長くなり、サイトのエンゲージメント向上に直結します。
  • 最先端技術(AI/WebGL)のショーケース
    AI(MediaPipe)を活用した実装例として、技術系キーワードでの検索流入や、SNSでの拡散による「サイテーション(言及)」獲得が期待できます。
  • LCP(Largest Contentful Paint)の最適化
    このシステムは数万個の頂点を扱いますが、プログラムコードで生成されるため、高画質な動画ファイルよりもデータ転送量が極めて小さく、高速なページ読み込みが可能です。

実装・運用時の注意点

SEOとユーザビリティを両立させるために、以下の運用ルールを推奨します。

  • カメラ使用のパーミッションと代替表示
    プライバシー保護のため、カメラの使用にはユーザーの許可が必要です。許可されない場合や、カメラ非搭載デバイスのために、自動アニメーション(オートデモ)へ切り替わるフォールバック処理を導入しています。
  • ハードウェア負荷への配慮
    AI推論と3D描画を同時に行うため、旧世代のスマートフォンでは動作が重くなる場合があります。本コードでは setPixelRatio を最適化し、描画負荷と美しさのバランスを調整済みです。
  • アクセシビリティの確保
    Canvas要素は検索エンジンが中身を理解しにくいため、<canvas> タグの代替テキストや、周囲のテキストで「AIカメラによるパーティクル操作デモであること」を明記し、SEO上の文脈を補強しています。

カスタマイズのテクニック(AIプロンプト活用のヒント)

JavaScript内の変数を変更することで、ブランドイメージに合わせた演出が可能です。

反応感度の調整
CONFIG.handInfluence に掛ける係数を変えることで、手の動きに対するパーティクルの「爆発力」を強めたり、逆に穏やかな変化にしたりすることが可能です。

粒子の密度を調整
particleCount: 15000 の数値を変更します。モバイル特化にするなら 5000、よりリッチにするなら 30000 に設定してください。

カラーテーマの変更
baseColor: new THREE.Color(‘#4facfe’) の16進数カラーコードを書き換えるだけで、ネオンブルーから桜色、ゴールドなど、瞬時に雰囲気を変えられます。

Prompts AIプロンプト

今回の高度なプログラムを生成するにあたり、Geminiへ一度の依頼で完成させるのではなく、「Google AI Studio」でベースとなるロジックを生成し、そのコードを「Gemini」に渡してさらにブラッシュアップするという二段階のステップを踏んでいます。

Geminiに直接でもある程度のものはできましたが、このフローを活用することで、複雑なライブラリ同士の連携や精度の高いインタラクションを実現することができました。
以下に、制作の鍵となったマスタープロンプトを公開します。この内容をAIに入力することで、同様のシステムを再現、あるいはさらなる拡張が可能です。

■実際に使ったプロンプト
Three.jsを使用してリアルタイムのインタラクティブな3Dパーティクルシステムを作成してください。
1. カメラを通じて両手の緊張と閉じを検出することで、パーティクルグループのスケーリングと拡張を制御します。
2. ハート/花/土星/仏像/花火などのテンプレートを選択できるパネルを提供します
3. パーティクルの色を調整するためのカラースセレクターをサポートします
4. パーティクルはジェスチャーの変更にリアルタイムで応答する必要があります。インターフェースはシンプルでモダンです。
5. インターフェースはシンプルでモダンです