PCのカメラの前で両手をかざすと、左手から赤い泡、右手から水色の泡が現れる。さらに両手を近づけると、二つの泡の色が混ざった紫色のエネルギーが広がる。
この体験は、専用アプリではなく、HTMLをブラウザで開いてカメラを許可するだけで動くWebアプリだ。手の動きをAIで認識し、ブラウザのGPU描画で粒子エフェクトを重ねている。
この記事の最後に全ソース掲載しています。htmlとしてダウンロードし動作確認できます。
カメラを使うため、周囲の安全を確認してから試してください。体験版は外部CDNのライブラリを読み込むため、インターネット接続が必要です。
この記事では、添付したHTMLの実装を題材に、「手を認識する」「画面上の座標へ変換する」「粒子を動かして描く」という三つの仕組みを解説する。
何が起きているのか
このアプリは、一つのHTMLファイルにHTML、CSS、JavaScriptをまとめた構成だ。外部から読み込む主なライブラリは次のとおりである。
| 技術 | このアプリでの役割 |
|---|---|
| HTML / CSS / JavaScript | 画面、カメラ起動、状態管理、アニメーションの制御 |
| MediaPipe Hands | カメラ映像から両手のランドマークを推定 |
| Three.js | WebGLを使い、赤い泡・水色の泡・エネルギーの粒子を描画 |
| Tailwind CSS | 画面上の案内やボタンのスタイル |
MediaPipe Handsは、1フレームの画像から手の21個のランドマークを推定する仕組みを提供している。MediaPipe Handsの公式ドキュメント このデモでは、手のひらの目安として中指の付け根に当たる landmarks[9] を使い、魔法を出す位置を決めている。
全体の流れは次のとおりだ。
カメラ映像
↓
MediaPipeが両手の座標と左右を推定
↓
座標をThree.jsの画面座標へ変換
↓
手が離れている → 赤い泡と水色の泡の粒子を発生
手が近い → 紫のエネルギー粒子を発生
↓
毎フレーム、粒子を更新してWebGL Canvasへ描画
映像・エフェクト・UIを重ねる
HTMLには、主に三つの表示層がある。
<video id="input_video" autoplay playsinline></video>
<canvas id="output_canvas"></canvas>
<div id="ui_layer">...</div>
- video:カメラ映像
- canvas:Three.jsが描く赤い泡・水色の泡・エネルギー
- ui_layer:タイトル、操作説明、開始ボタン
CSSでは、映像とCanvasを画面全体へ重ねている。カメラ映像だけに transform: scaleX(-1) を指定して鏡のように表示し、その上へ半透明のWebGLエフェクトを重ねる。
「現実の映像」と「計算で描く世界」を別のレイヤーにすると、UIや映像を作り直さずに、エフェクトだけを差し替えられる。AR風のWeb体験を作るときの基本的な設計だ。
手の認識:AIが返す座標を受け取る
手の検出器は、両手を対象にし、検出と追跡の信頼度を0.7へ設定している。
hands.setOptions({
maxNumHands: 2,
modelComplexity: 1,
minDetectionConfidence: 0.7,
minTrackingConfidence: 0.7
});
hands.onResults(onResults);
カメラの各フレームは Hands へ渡され、結果が onResults に返る。
const landmarks = results.multiHandLandmarks[i];
const classification = results.multiHandedness[i];
const palmCenter = landmarks[9];
landmarks[9] は中指の付け根に当たる点だ。手のひらの厳密な中心ではないが、手首や指先より安定して「手から魔法が出る位置」の目安にしやすい。
左右反転は、体験のための小さな落とし穴
このデモでは、カメラ映像をCSSで鏡像表示している。一方、MediaPipeへ渡す映像自体は反転していない。このため、AIが返す Right と、画面上で見える右側・左側の関係が直感と逆になる。
添付プログラムは、この差をコメント付きで処理している。
if (classification.label === "Right") {
handPositions.left = screenPos; // 実際の左手 → 赤い泡
} else {
handPositions.right = screenPos; // 実際の右手 → 水色の泡
}
映像認識の結果をグラフィックへ重ねるときは、映像を反転したのか、認識器へ渡す画像を反転したのか、画面座標を反転したのかを一か所で整理する。左右がおかしいと感じたら、色やパーティクルを疑う前に、この変換経路を確認するとよい。
座標変換:0〜1の手の位置を、画面の−1〜1へ写す
MediaPipeの座標は、画像の左上を (0, 0)、右下を (1, 1) とする正規化座標だ。対して、このアプリのThree.jsは、画面の中心を (0, 0) とする直交カメラを使っている。
両者をつなぐのが getThreePos 関数である。
function getThreePos(normX, normY) {
const screenX = (normX - 0.5) * -2.0;
const screenY = (normY - 0.5) * -2.0;
return new THREE.Vector3(screenX, screenY, -1);
}
この計算には二つの意味がある。
- normX - 0.5 で、0〜1を画面中心基準へ移す。
- 2倍して、−1〜1の範囲へ広げる。
X座標にマイナスを付けているのは、鏡像表示に合わせるためだ。映像認識の結果を画面上のエフェクトに重ねるときは、座標系の向きと原点をそろえることが最初の重要ポイントになる。
パーティクルの設計:大量の粒を、まとめて扱う
赤い泡や水色の泡を一つの大きな図形で描くのではなく、小さな粒を大量に動かして表現している。この粒をパーティクルという。
添付HTMLの中心にある ParticleSystem クラスは、赤い泡・水色の泡・エネルギーに共通する処理をまとめた設計図だ。
this.positions = new Float32Array(this.maxParticles * 3);
this.lifetimes = new Float32Array(this.maxParticles);
this.geometry.setAttribute(
"position",
new THREE.BufferAttribute(this.positions, 3)
);
ポイントは、粒を一個ずつThree.jsのオブジェクトとして作らず、位置と寿命を配列へまとめていることだ。
- positions:各粒のX・Y・Z座標
- velocities:各粒の移動速度
- lifetimes:現在の寿命
- maxLifetimes:粒ごとの寿命の長さ
Three.jsの BufferGeometry と Points を使うと、多数の点をまとめて描画できる。Three.jsのPointsドキュメント 粒が増えても、毎フレームのJavaScriptオブジェクト生成を抑えやすく、GPUへ渡すデータも管理しやすい。
上限は、設定オブジェクトで決めている。
const config = {
maxParticlesPerEffect: 500,
energyThreshold: 0.2
};
maxParticlesPerEffect は、演出の派手さとフレームレートのトレードオフを決める値だ。エネルギーはこの3倍の上限を持つため、無制限に増やさない設計が重要になる。
シェーダー:粒の色、透明度、光り方をGPUで決める
パーティクルの見た目は THREE.ShaderMaterial で作られている。シェーダーとは、GPU上で各頂点や各ピクセルをどう描くかを指定する小さなプログラムだ。
このデモの基本パーティクルは、次のような考え方で描かれる。
- 頂点シェーダーで粒の大きさと寿命を渡す
- フラグメントシェーダーで円形の縁とハイライトを描く
- 寿命が減るほど透明にする
- 加算合成で、粒が重なるほど明るく見せる
今回の泡は、中心を塗りつぶすのではなく、半透明の縁と左上の白いハイライトを描いている。THREE.AdditiveBlending によって重なった光を加算すると、泡とエネルギーの「光っている感じ」が生まれる。
赤い泡・水色の泡・エネルギーは、「初速」と「力」を変えて作る
共通のパーティクル機構へ、異なる物理っぽいルールを与えると、同じ粒でも印象が変わる。
赤い泡:上向きの初速と、ゆるやかな浮遊
getInitialVelocity() {
return new THREE.Vector3(
(Math.random() - 0.5) * 0.008,
Math.random() * 0.012 + 0.006,
(Math.random() - 0.5) * 0.006
);
}
applyForces(velocity) {
velocity.y += 0.00025;
velocity.x += (Math.random() - 0.5) * 0.0012;
}
赤い泡は、初速を小さくして、Y方向へゆっくり浮かせる。横方向へごく小さな乱数を加えることで、一直線に上がらない有機的な揺らぎを作っている。
水色の泡:上向きの初速と、横へ揺れる流れ
getInitialVelocity() {
return new THREE.Vector3(
(Math.random() - 0.5) * 0.012,
Math.random() * 0.009 + 0.004,
(Math.random() - 0.5) * 0.008
);
}
applyForces(velocity, positions, index) {
velocity.y += 0.00018;
const x = positions[index];
const y = positions[index + 1];
velocity.x += Math.sin(y * 8.0 + x * 4.0) * 0.0008;
}
水色の泡も上へ浮かせるが、サイン波を使って横方向へゆっくり揺らす。色だけでなく、動きにも差をつけることで、左右の泡を見分けやすくしている。
合体エネルギー:球状の初速と時間変化する色
両手が近づくと、各手の中点から紫の粒を10個ずつ発生させる。
if (distance < config.energyThreshold) {
energyEffect.spawn(mergeCenter.x, mergeCenter.y, 10);
redBubbleEffect.clear();
cyanBubbleEffect.clear();
}
エネルギーの粒は、ランダムな球面方向へ大きく飛ぶ。さらに、フラグメントシェーダーで sin と cos を使い、位置と時間に応じて赤・水色・紫を混ぜる。これがマーブル状にうねる見た目の正体だ。
「物理的に正しい」ことより、見た人が赤い泡・水色の泡・合体エネルギーだと感じる運動規則を選ぶ。体験型アプリでは、この見た目の説得力が技術の価値になる。
合体判定:二点の距離を計算する
必殺技の判定は、難しい機械学習ではない。両手の距離を計算し、設定値より近いかを確かめている。
const dx = handPositions.left.x - handPositions.right.x;
const dy = handPositions.left.y - handPositions.right.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < config.energyThreshold) {
// 合体エネルギー
}
これは二次元平面での距離を求める、ピタゴラスの定理そのものだ。AIは「手がどこにあるか」を教え、通常のプログラムが「その距離なら何を起こすか」を決める。
AI・認識モデル:画像や音声から状態を推定する
通常のプログラム:明確なルール、しきい値、演出、画面遷移を決める
曖昧な認識をAIへ任せ、サービスのルールはコードとして明示する。この分離があると、調整とテストがしやすい。
毎フレーム更新するアニメーションループ
体験を滑らかに見せる心臓部が animate 関数だ。
function animate() {
requestAnimationFrame(animate);
const deltaTime = (performance.now() - lastTime) / 1000;
redBubbleEffect.update(deltaTime);
cyanBubbleEffect.update(deltaTime);
energyEffect.update(deltaTime);
renderer.render(scene, camera);
}
requestAnimationFrame は、ブラウザの描画タイミングに合わせて次のフレームを予約する。各フレームで、粒を発生させ、寿命を減らし、位置を更新し、描画する。
このコードは寿命の減少に deltaTime を使っているため、粒が消える時間はフレームレートの差を受けにくい。一方、粒の位置更新や力の加算はフレームごとの値になっている。端末によって動きの速さが変わるのを抑えたいなら、時間差分を掛ける改善ができる。
const step = deltaTime * 60;
velocity.y += 0.001 * step;
positions[i * 3] += velocity.x * step;
positions[i * 3 + 1] += velocity.y * step;
「動けばよい」から一歩進み、端末差まで考える。これがリアルタイムWebアプリを育てるときの大事なポイントだ。
自分で改造してみよう
まずは、設定値や色を一つだけ変えて、結果を観察してみよう。
1. 赤い泡を緑にする
RedBubbleEffect の super の先頭引数を変える。
// 変更前
super(0xff3b30, 34.0, config.maxParticlesPerEffect);
// 変更後
super(0x00ff00, 40.0, config.maxParticlesPerEffect);
2. 粒を増やして派手にする
// 変更前
maxParticlesPerEffect: 500,
// 変更後
maxParticlesPerEffect: 1000,
粒を増やすほど派手になるが、端末によっては重くなる。見た目だけでなく、描画が滑らかかを確認する。
3. 合体しやすくする
// 変更前
energyThreshold: 0.2,
// 変更後
energyThreshold: 0.35,
値を大きくすると、手が少し離れていても合体する。小さくすると、両手をより近づける必要がある。
4. 水色の泡をもっと軽く浮かせる
// 変更前:ゆるやかな浮遊
velocity.y += 0.00018;
// 変更後:より強い浮力
velocity.y += 0.001;
数値を大きくするほど、泡は速く上昇する。数式を変えることは、画面上の世界の物理法則を変えることに近い。
公開前に確認したいこと
このデモはカメラを使う。カメラ映像を取得する getUserMedia は、ユーザーの許可が必要で、通常はHTTPSなどの安全なコンテキストでしか利用できない。MDNのgetUserMedia解説
公開前には、次を確認する。
- カメラを使う目的と、映像をどこへ送るかを画面へ明記する
- HTTPSで配信し、許可されなかった場合の案内を用意する
- スマートフォン、低性能PC、複数ブラウザで動作を試す
- CDNのライブラリをバージョン固定し、将来の更新で壊れないようにする
- 画面録画・スクリーンショット・ログへ映像を残すなら、保存範囲と期間を決める
- 過度なパーティクル数で端末を重くしない
添付版はThree.jsとMediaPipeをCDNから直接読み込むため、試作として始めやすい。その一方で、本格公開では依存ライブラリのバージョン固定、ライセンス確認、エラー監視、プライバシー表示まで整えると安心だ。
まとめ:技術の組み合わせが「自分が魔法を使う」体験になる
このアプリは、難しい技術を一つだけ使っているわけではない。
- カメラから手の位置を取得する。
- 座標を画面のエフェクト位置へ変換する。
- 粒へ速度・寿命・力を与える。
- 毎フレーム更新して描く。
- 手の距離というルールで、演出を切り替える。
この役割分担があるから、赤い泡を雷へ変える、水色の泡を桜吹雪へ変える、手の形で別の技を出すといった拡張ができる。
技術の面白さは、ライブラリ名を知ることだけではない。「人の動きに、画面の世界がどう反応したら楽しいか」を考え、数値とコードで試し、すぐ体験できる形にすることにある。
まずは添付HTMLを開き、色、粒の数、合体距離を一つ変えてみよう。そこから、自分だけの魔法を設計するプログラミングが始まる。
作成日:2026年7月25日
サンプルコード(全ソース)
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Magic Hands Demo</title>
<!-- Tailwind CSS for styling -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Three.js for 3D rendering and particle effects -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<!-- MediaPipe Hands for hand tracking -->
<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/drawing_utils/drawing_utils.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/hands/hands.js" crossorigin="anonymous"></script>
<style>
body {
margin: 0;
overflow: hidden;
background-color: #000;
color: white;
font-family: 'Inter', sans-serif;
}
#container {
position: relative;
width: 100vw;
height: 100vh;
}
/* Video feed from camera */
#input_video {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
transform: scaleX(-1); /* Mirror image */
z-index: 1;
opacity: 0.7; /* Slightly dim the video to make effects pop */
}
/* WebGL Canvas for effects */
#output_canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 2;
pointer-events: none; /* Let clicks pass through */
}
#ui_layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 10;
pointer-events: none;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 20px;
}
/* Loading overlay */
#loading {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.9);
z-index: 20;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
pointer-events: auto; /* Catch clicks while loading */
}
.spinner {
border: 4px solid rgba(255, 255, 255, 0.1);
width: 50px;
height: 50px;
border-radius: 50%;
border-left-color: #09f;
animation: spin 1s linear infinite;
margin-bottom: 20px;
}
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
.start-btn {
background: linear-gradient(45deg, #ff416c, #ff4b2b);
border: none;
padding: 15px 30px;
color: white;
font-size: 1.2rem;
border-radius: 30px;
cursor: pointer;
box-shadow: 0 4px 15px rgba(255, 65, 108, 0.4);
transition: transform 0.2s, box-shadow 0.2s;
pointer-events: auto;
}
.start-btn:hover {
transform: scale(1.05);
box-shadow: 0 6px 20px rgba(255, 65, 108, 0.6);
}
.start-btn:disabled {
background: #555;
box-shadow: none;
cursor: not-allowed;
transform: none;
}
</style>
</head>
<body>
<div id="container">
<video id="input_video" autoplay playsinline></video>
<!-- We will use a regular canvas for drawing hands (optional, mainly for debugging),
but Three.js will handle the main visual effects -->
<canvas id="output_canvas"></canvas>
<div id="ui_layer">
<div class="text-center w-full">
<h1 class="text-4xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-red-500 via-purple-500 to-blue-500 drop-shadow-[0_0_10px_rgba(255,255,255,0.8)]">
Elemental Magic
</h1>
<p class="text-lg mt-2 drop-shadow-md">両手をカメラにかざしてください。近づけると...</p>
</div>
<div class="flex justify-between w-full text-sm opacity-70">
<div>左手: <span class="text-red-400 font-bold">赤い泡 (Red Bubbles)</span></div>
<div>右手: <span class="text-cyan-300 font-bold">水色の泡 (Cyan Bubbles)</span></div>
</div>
</div>
<div id="loading">
<div class="spinner" id="spinner"></div>
<h2 id="loading-text" class="text-2xl font-bold mb-6">モデルをロード中...</h2>
<button id="start-btn" class="start-btn hidden">カメラを起動して開始</button>
<p class="text-sm mt-4 text-gray-400 max-w-md text-center">
※カメラのアクセス許可が必要です。<br>
※処理が重いため、スマートフォンよりPCでの動作を推奨します。
</p>
</div>
</div>
<script>
// --- Configuration & State ---
const config = {
maxParticlesPerEffect: 500,
energyThreshold: 0.2, // Distance between hands to trigger merge effect (normalized coordinates)
};
let isInitialized = false;
let handPositions = { left: null, right: null }; // Normalized coordinates (0-1)
// --- DOM Elements ---
const videoElement = document.getElementById('input_video');
const canvasElement = document.getElementById('output_canvas');
const loadingScreen = document.getElementById('loading');
const spinner = document.getElementById('spinner');
const loadingText = document.getElementById('loading-text');
const startBtn = document.getElementById('start-btn');
// --- Three.js Setup ---
const scene = new THREE.Scene();
// We use an OrthographicCamera to easily map 2D coordinates (0-1) to the screen
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 10);
camera.position.z = 1;
const renderer = new THREE.WebGLRenderer({ canvas: canvasElement, alpha: true, antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
// Handle window resize
window.addEventListener('resize', () => {
renderer.setSize(window.innerWidth, window.innerHeight);
// Orthographic camera doesn't need aspect ratio update for our specific usage here
// as we map normalized coordinates directly based on screen dimensions later.
});
// --- Particle System Classes ---
// Base class for particle systems
class ParticleSystem {
constructor(color, size, maxParticles, textureUrl = null) {
this.geometry = new THREE.BufferGeometry();
this.maxParticles = maxParticles;
this.particleCount = 0;
// Attributes
this.positions = new Float32Array(this.maxParticles * 3);
this.velocities = [];
this.lifetimes = new Float32Array(this.maxParticles);
this.maxLifetimes = new Float32Array(this.maxParticles);
this.geometry.setAttribute('position', new THREE.BufferAttribute(this.positions, 3));
this.geometry.setAttribute('lifetime', new THREE.BufferAttribute(this.lifetimes, 1));
// Custom shader material for better control over fading and coloring
this.material = new THREE.ShaderMaterial({
uniforms: {
color: { value: new THREE.Color(color) },
pointSize: { value: size * window.devicePixelRatio },
// Simple radial gradient texture if URL not provided
},
vertexShader: `
attribute float lifetime;
varying float vAlpha;
uniform float pointSize;
void main() {
vAlpha = lifetime;
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
// Size attenuation
gl_PointSize = pointSize * (1.0 / -mvPosition.z);
// Make newly spawned particles larger, fading out
gl_PointSize *= (0.5 + 0.5 * lifetime);
gl_Position = projectionMatrix * mvPosition;
}
`,
fragmentShader: `
uniform vec3 color;
varying float vAlpha;
void main() {
// 透明な泡の縁と、左上のハイライトを描く
vec2 xy = gl_PointCoord.xy - vec2(0.5);
float radius = length(xy);
if(radius > 0.5) discard;
float rim = smoothstep(0.30, 0.48, radius);
float highlight = 1.0 - smoothstep(0.03, 0.18, length(xy - vec2(-0.14, 0.14)));
float alpha = (0.08 + rim * 0.54 + highlight * 0.38) * vAlpha;
vec3 bubbleColor = mix(color, vec3(1.0), highlight * 0.55);
gl_FragColor = vec4(bubbleColor, alpha);
}
`,
transparent: true,
blending: THREE.AdditiveBlending,
depthWrite: false
});
this.points = new THREE.Points(this.geometry, this.material);
this.points.frustumCulled = false; // Prevent culling when off-screen slightly
scene.add(this.points);
this.isActive = false;
this.targetPos = new THREE.Vector3(0, 0, -1);
}
spawn(x, y, amount) {
this.isActive = true;
this.targetPos.set(x, y, -1);
for(let i = 0; i < amount; i++) {
if (this.particleCount >= this.maxParticles) return; // Full
// Find next available slot (simplified: just append, reset if full)
const idx = this.particleCount;
// Spawn around target position
this.positions[idx * 3] = x + (Math.random() - 0.5) * 0.1;
this.positions[idx * 3 + 1] = y + (Math.random() - 0.5) * 0.1;
this.positions[idx * 3 + 2] = -1 + (Math.random() - 0.5) * 0.1;
// Setup velocity based on specific effect (override in subclasses)
this.velocities[idx] = this.getInitialVelocity();
this.maxLifetimes[idx] = this.getInitialLifetime();
this.lifetimes[idx] = 1.0; // Normalized 1 to 0
this.particleCount++;
}
}
getInitialVelocity() {
return new THREE.Vector3(
(Math.random() - 0.5) * 0.02,
(Math.random() - 0.5) * 0.02,
(Math.random() - 0.5) * 0.02
);
}
getInitialLifetime() {
return 1.0 + Math.random(); // Seconds
}
update(deltaTime) {
if (!this.isActive && this.particleCount === 0) return;
let aliveCount = 0;
const positions = this.geometry.attributes.position.array;
const lifetimes = this.geometry.attributes.lifetime.array;
for (let i = 0; i < this.particleCount; i++) {
// Decrease lifetime
lifetimes[i] -= deltaTime / this.maxLifetimes[i];
if (lifetimes[i] > 0) {
// Update position
const vel = this.velocities[i];
this.applyForces(vel, positions, i * 3);
positions[i * 3] += vel.x;
positions[i * 3 + 1] += vel.y;
positions[i * 3 + 2] += vel.z;
// Compact arrays (move alive particles to front)
if (aliveCount !== i) {
positions[aliveCount * 3] = positions[i * 3];
positions[aliveCount * 3 + 1] = positions[i * 3 + 1];
positions[aliveCount * 3 + 2] = positions[i * 3 + 2];
lifetimes[aliveCount] = lifetimes[i];
this.velocities[aliveCount] = this.velocities[i];
this.maxLifetimes[aliveCount] = this.maxLifetimes[i];
}
aliveCount++;
}
}
this.particleCount = aliveCount;
this.geometry.setDrawRange(0, this.particleCount);
this.geometry.attributes.position.needsUpdate = true;
this.geometry.attributes.lifetime.needsUpdate = true;
this.isActive = false; // Reset active flag, must be set every frame by tracking
}
applyForces(velocity, positions, index) {
// Override in subclass
}
clear() {
this.particleCount = 0;
this.geometry.setDrawRange(0, 0);
}
}
// --- Specific Bubble & Energy Effects ---
class RedBubbleEffect extends ParticleSystem {
constructor() {
// 透明感のある赤い泡
super(0xff3b30, 34.0, config.maxParticlesPerEffect);
}
getInitialVelocity() {
// 赤い泡はゆっくり上へ浮かぶ
return new THREE.Vector3(
(Math.random() - 0.5) * 0.008,
Math.random() * 0.012 + 0.006,
(Math.random() - 0.5) * 0.006
);
}
applyForces(velocity, positions, index) {
// 浮力と、小さな横揺れ
velocity.y += 0.00025;
velocity.x += (Math.random() - 0.5) * 0.0012;
}
}
class CyanBubbleEffect extends ParticleSystem {
constructor() {
// 水色の泡
super(0x55eaff, 30.0, config.maxParticlesPerEffect);
}
getInitialVelocity() {
// 水色の泡は少し横へ揺れながら上がる
return new THREE.Vector3(
(Math.random() - 0.5) * 0.012,
Math.random() * 0.009 + 0.004,
(Math.random() - 0.5) * 0.008
);
}
applyForces(velocity, positions, index) {
// ゆるやかな浮力と横方向のうねり
velocity.y += 0.00018;
const x = positions[index];
const y = positions[index + 1];
velocity.x += Math.sin(y * 8.0 + x * 4.0) * 0.0008;
}
}
class EnergyEffect extends ParticleSystem {
constructor() {
// ベースの色を紫(Magenta/Purple)に設定
super(0xcc00ff, 120.0, config.maxParticlesPerEffect * 3);
this.time = 0;
// 独自のシェーダーで、赤と青が混ざり合って紫になる様子を動的に表現
this.material = new THREE.ShaderMaterial({
uniforms: {
colorRed: { value: new THREE.Color(0xff3b30) }, // 赤い泡
colorBlue: { value: new THREE.Color(0x55eaff) }, // 水色の泡
colorPurple: { value: new THREE.Color(0xcc00ff) }, // 混ざった色(紫)
pointSize: { value: 120.0 * window.devicePixelRatio },
time: { value: 0.0 }
},
vertexShader: `
attribute float lifetime;
varying float vAlpha;
varying vec3 vPos;
uniform float pointSize;
void main() {
vAlpha = lifetime;
vPos = position; // 空間座標をフラグメントシェーダーに渡す
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
gl_PointSize = pointSize * (1.0 / -mvPosition.z);
gl_PointSize *= (0.3 + 0.7 * lifetime);
gl_Position = projectionMatrix * mvPosition;
}
`,
fragmentShader: `
uniform vec3 colorRed;
uniform vec3 colorBlue;
uniform vec3 colorPurple;
uniform float time;
varying float vAlpha;
varying vec3 vPos;
void main() {
vec2 xy = gl_PointCoord.xy - vec2(0.5);
float ll = length(xy);
if(ll > 0.5) discard;
float alpha = (0.5 - ll) * 2.0 * vAlpha;
// 空間座標(vPos)と時間(time)を使って、赤・青・紫がマーブル状にうねる波を作る
float wave = sin(vPos.x * 15.0 + time * 8.0) * cos(vPos.y * 15.0 - time * 6.0);
vec3 baseColor;
if (wave > 0.4) {
baseColor = mix(colorPurple, colorRed, (wave - 0.4) * 1.6);
} else if (wave < -0.4) {
baseColor = mix(colorPurple, colorBlue, (-wave - 0.4) * 1.6);
} else {
baseColor = colorPurple;
}
// 中心をさらに明るく(白く)発光させる
float glow = pow((0.5 - ll) * 2.0, 2.5);
vec3 finalColor = mix(baseColor, vec3(1.0, 1.0, 1.0), glow);
gl_FragColor = vec4(finalColor, alpha);
}
`,
transparent: true,
blending: THREE.AdditiveBlending,
depthWrite: false
});
this.points.material = this.material;
}
getInitialVelocity() {
// 四方八方に爆発的に広がる
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos((Math.random() * 2) - 1);
const speed = Math.random() * 0.1 + 0.05;
return new THREE.Vector3(
speed * Math.sin(phi) * Math.cos(theta),
speed * Math.sin(phi) * Math.sin(theta),
speed * Math.cos(phi)
);
}
getInitialLifetime() {
return 0.8 + Math.random() * 0.4;
}
applyForces(velocity, positions, index) {
// 空気抵抗(徐々に減速する)
velocity.multiplyScalar(0.95);
}
update(deltaTime) {
super.update(deltaTime);
// 時間を更新してシェーダーに渡すことでアニメーションさせる
this.time += deltaTime;
this.material.uniforms.time.value = this.time;
}
}
// Instantiate effects
const redBubbleEffect = new RedBubbleEffect();
const cyanBubbleEffect = new CyanBubbleEffect();
const energyEffect = new EnergyEffect();
// --- Helper function to convert normalized coords to screen space ---
// MediaPipe returns {x,y} where 0,0 is top-left, 1,1 is bottom-right.
// Video is mirrored, so x is inverted relative to screen.
// Three.js OrthographicCamera(-1, 1, 1, -1) has 0,0 at center.
function getThreePos(normX, normY) {
// Handle mirroring: if video is transform: scaleX(-1),
// a hand on physical left (appears right on screen) has high X.
// Screen space: -1 (left) to 1 (right)
// Since video element is mirrored via CSS scaleX(-1):
// MediaPipe x=0 (right edge of image, left edge of screen)
// MediaPipe x=1 (left edge of image, right edge of screen)
const screenX = (normX - 0.5) * -2.0;
// Y is standard: MediaPipe 0 (top) to 1 (bottom)
// Three.js 1 (top) to -1 (bottom)
const screenY = (normY - 0.5) * -2.0;
return { x: screenX, y: screenY };
}
// --- MediaPipe Hands Setup ---
function onResults(results) {
// Reset tracking state
handPositions.left = null;
handPositions.right = null;
if (results.multiHandLandmarks && results.multiHandedness) {
for (let i = 0; i < results.multiHandLandmarks.length; i++) {
const landmarks = results.multiHandLandmarks[i];
const classification = results.multiHandedness[i];
// Get the center of the palm (using landmark 9, middle finger mcp)
const palmCenter = landmarks[9];
const screenPos = getThreePos(palmCenter.x, palmCenter.y);
// MediaPipeは入力画像が反転していない前提で左右を判定します。
// カメラの映像はそのままMediaPipeに送られ、画面表示上だけCSSで反転させているため、
// MediaPipeが「Right(右)」と判定した手は、実際のユーザーの「物理的な左手」になります。
if (classification.label === "Right") {
// ユーザーの実際の左手 -> 赤い泡
handPositions.left = screenPos;
} else {
// ユーザーの実際の右手 -> 水色の泡
handPositions.right = screenPos;
}
}
}
}
const hands = new Hands({locateFile: (file) => {
return `https://cdn.jsdelivr.net/npm/@mediapipe/hands/${file}`;
}});
hands.setOptions({
maxNumHands: 2,
modelComplexity: 1, // 0 for faster, 1 for more accurate
minDetectionConfidence: 0.7,
minTrackingConfidence: 0.7
});
hands.onResults(onResults);
// --- Camera Setup ---
let cameraUtil;
async function startCamera() {
startBtn.disabled = true;
startBtn.innerText = "起動中...";
try {
// First, trigger a dummy process to ensure models are loaded
// (MediaPipe usually loads lazily on first frame)
// But we use CameraUtils which handles the stream.
cameraUtil = new Camera(videoElement, {
onFrame: async () => {
await hands.send({image: videoElement});
},
width: 640,
height: 480
});
await cameraUtil.start();
// Hide loading screen, show UI
loadingScreen.style.opacity = 0;
setTimeout(() => {
loadingScreen.style.display = 'none';
}, 500);
isInitialized = true;
} catch (error) {
console.error("Camera start failed:", error);
loadingText.innerText = "カメラの起動に失敗しました";
startBtn.innerText = "再試行";
startBtn.disabled = false;
alert("カメラへのアクセスを許可してください。");
}
}
// --- Main Animation Loop ---
let lastTime = performance.now();
function animate() {
requestAnimationFrame(animate);
const currentTime = performance.now();
const deltaTime = (currentTime - lastTime) / 1000; // in seconds
lastTime = currentTime;
if (isInitialized) {
// Logic for emitting particles based on hand positions
let isMerged = false;
let mergeCenter = { x: 0, y: 0 };
// Check distance between hands
if (handPositions.left && handPositions.right) {
const dx = handPositions.left.x - handPositions.right.x;
const dy = handPositions.left.y - handPositions.right.y;
const distance = Math.sqrt(dx*dx + dy*dy);
if (distance < config.energyThreshold) {
isMerged = true;
mergeCenter.x = (handPositions.left.x + handPositions.right.x) / 2;
mergeCenter.y = (handPositions.left.y + handPositions.right.y) / 2;
}
}
if (isMerged) {
// Generate massive energy
energyEffect.spawn(mergeCenter.x, mergeCenter.y, 10);
// Clear individual effects or reduce them
redBubbleEffect.clear();
cyanBubbleEffect.clear();
// Optional: Camera shake or bloom effect could go here for polish
} else {
// Generate individual bubbles
if (handPositions.left) {
redBubbleEffect.spawn(handPositions.left.x, handPositions.left.y, 3);
}
if (handPositions.right) {
cyanBubbleEffect.spawn(handPositions.right.x, handPositions.right.y, 3);
}
// Clear energy if hands separated
// (It will naturally fade out due to lifetime logic)
}
}
// Update particle systems
redBubbleEffect.update(deltaTime);
cyanBubbleEffect.update(deltaTime);
energyEffect.update(deltaTime);
// Render
renderer.render(scene, camera);
}
// --- Initialization Flow ---
window.onload = () => {
// Simulating model load time visually, then enabling button
setTimeout(() => {
spinner.style.display = 'none';
loadingText.innerText = "準備完了";
startBtn.classList.remove('hidden');
startBtn.addEventListener('click', startCamera);
}, 1500); // Artificial delay to ensure user sees instructions
// Start Three.js loop immediately (will just render empty scene until initialized)
animate();
};
</script>
</body>