猫の手ツール

Algorithm & UI/UX

Canvas 2Dで実現する「レンズ歪み」とレスポンシブ座標変換の舞台裏

単なる円形のトリミングではなく、望遠鏡特有の「覗き込んでいる感覚」を演出するにはどうすればよいか。クリッピング領域の制御と、ピクセル解像度の動的マッピングについて解説します。

1. レスポンシブなドラッグ操作を支える「正規化座標マッピング」

このツールでは、Canvasの描画サイズを固定(例:1000x1000px)しつつ、画面上ではCSSによって伸縮させています。このとき、ユーザーがスマホでタップした座標(CSSピクセル)を、Canvas内部の座標系に正確に変換する必要があります。

getBoundingClientRect() から得られる実際の表示幅と、Canvasの論理的な解像度の比率を用いることで、どのデバイスでも「指で触った場所を直接動かす」という直感的な操作性を担保しています。

function getNormalizedPosition(event, canvas) {
    const rect = canvas.getBoundingClientRect();
    
    // クライアント座標(画面上のピクセル)を取得
    const clientX = event.touches ? event.touches[0].clientX : event.clientX;
    const clientY = event.touches ? event.touches[0].clientY : event.clientY;

    // 表示サイズと内部解像度の比率を計算し、座標を変換
    return {
        x: (clientX - rect.left) * (canvas.width / rect.width),
        y: (clientY - rect.top) * (canvas.height / rect.height)
    };
}

2. クリッピング領域内での「レンズ歪み」シミュレーション

本ツールの核心となるアルゴリズムは、ctx.clip() による円形マスクと、マスク内での動的な拡大率制御の組み合わせです。

ただ円形に切り取るだけでは平面的な印象になりますが、本実装では「レンズの歪み(歪曲収差)」を表現するため、クリッピング領域内でのみ画像をさらに拡大(Magnification)させています。スライダーの値に基づいて画像の中心点をオフセットさせつつスケールをかけることで、レンズ越しに覗いているような奥行きのある視覚効果を生み出しています。

// 描画ロジックの核心部分
function renderLensEffect(ctx, img, config) {
    ctx.save();
    
    // 1. 円形のクリッピング領域を作成
    ctx.beginPath();
    // 独自のチューニング値に基づいた半径で円を描画
    ctx.arc(centerX, centerY, lensRadius, 0, Math.PI * 2);
    ctx.clip();

    // 2. 歪みを表現するための拡大計算
    // ここにレンズ特有の拡大率とオフセットの計算処理が入ります
    const lensMagnification = // 独自のチューニング値に基づいた倍率;
    
    // 3. 拡大した画像を描画
    ctx.drawImage(img, lensDx, lensDy, lensDw, lensDh);
    
    ctx.restore();
}

3. Radial Gradientによる周辺減光(ビネット)の演出

望遠鏡のリアリティを決定づけるのが、円のフチに向かって暗くなっていく「周辺減光」です。これを実現するために、円形クリッピングの境界に合わせて createRadialGradient を生成しています。

グラデーションの開始地点をレンズ半径の内側に設定し、境界線(エッジ)に向かって不透明度を高めることで、視線を中央へ誘導するシネマティックな質感を加えています。

function applyVignette(ctx, centerX, centerY, radius, darkAmount) {
    // グラデーションの開始位置を微調整
    const innerStart = radius * (// 独自のチューニング値);
    
    const grad = ctx.createRadialGradient(centerX, centerY, innerStart, centerX, centerY, radius);
    
    // 透明から黒へのグラデーションを定義
    grad.addColorStop(0, 'rgba(0,0,0,0)');
    grad.addColorStop(1, `rgba(0,0,0,${darkAmount / // 抽象的な係数})`);
    
    ctx.fillStyle = grad;
    // レンズ領域のみを塗りつぶし
    ctx.fillRect(centerX - radius, centerY - radius, radius * 2, radius * 2);
}

Developer's Note

このツールを開発する際、最も意識したのは「プライバシーを守りながらおもしろい体験を提供する」ことです。

画像加工アプリは数多くありますが、中にはサーバーに画像をアップロードして処理するものも少なくありません。私たちは、HEIC変換を含めたすべての重い処理を heic2any などのライブラリを活用してクライアントサイドで完結させました。

「望遠鏡で誰かを覗いているような写真」は、時として非常にプライベートな場所で撮影されます。だからこそ、完全にデバイス内で完結するアーキテクチャは譲れないこだわりでした。ユーザーの皆さんが、安心して「スパイごっこ」を楽しめるツールになれば幸いです。