WebGLで遊んでみた！ 3D空間でカメラを操るWebアプリの作成

## はじめに
WebGLは、ブラウザ上で3Dグラフィックスを表示するための技術です。今回はWebGLを使って、3D空間でカメラを操作して探索できるアプリケーションを作成しました。（基本的にChat GPTで調べています）

出来あがったものが [こちら](https://ken1math.github.io/my-website/template.html?%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20title=3d%E7%A9%BA%E9%96%93%E3%81%A8%E7%AB%8B%E6%96%B9%E4%BD%93%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20&linkText=WebGL%E3%81%A7%E9%81%8A%E3%82%93%E3%81%A7%E3%81%BF%E3%81%9F%EF%BC%81%203D%E7%A9%BA%E9%96%93%E3%81%A7%E3%82%AB%E3%83%A1%E3%83%A9%E3%82%92%E5%8B%95%E3%81%8B%E3%81%97%E3%81%A6%E7%AB%8B%E6%96%B9%E4%BD%93%E3%82%92%E6%93%8D%E3%82%8BWeb%E3%82%A2%E3%83%97%E3%83%AA%E3%81%AE%E4%BD%9C%E6%88%90%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20&linkUrl=https://qiita.com/ken1math/items/7f07d1bf03f36c580e2f%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20&iframeUrl=webglTest.html)


## できること
- **移動**: WASDキーを使って現在向いている方向に移動。カメラ位置は常に床の上に保持、高さは一定。左下のスライダーでスピード変更可能。
- **カメラ操作**: クリックしながらマウスで操作。Qキーを押すとカメラの上下の向きが元に戻る。


## 使った技術とライブラリ
- WebGL: ブラウザで3Dコンテンツを表示するための技術
- Three.js: WebGLを簡単に扱うためのJavaScriptライブラリ


## コードの概要

<details><summary>コード全文</summary>

```html

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>WebGL 3D Object with Camera Control</title>
    <style>
        /* body と canvas のスタイルを簡潔にまとめる */
        body { margin: 0; font-family: Arial, sans-serif; }
        canvas { display: block; } /* Three.jsで描画を行うために自動的に生成される */

        /* 共通のスタイルをまとめる */
        .control-box {
            position: absolute;
            color: white;
            font-size: 16px;
            z-index: 1;
            background-color: rgba(0, 0, 0, 0.7);
            padding: 10px;
            border-radius: 5px;
        }

        #instructions { top: 10px; left: 10px; }
        #coordinates { bottom: 60px; left: 10px; }
        #speedControl { bottom: 10px; left: 10px; }

        input[type="range"] { width: 200px; }
    </style>
</head>

<body>

    <!-- 操作方法の表示 -->
    <div id="instructions" class="control-box">
        <p><strong>操作方法：</strong></p>
        <ul>
            <li>視点:マウス(クリック)</li>
            <li>視点リセット:Q</li>
            <li>移動:WASD</li>
        </ul>
    </div>

    <!-- カメラ座標の表示 -->
    <div id="coordinates" class="control-box">
        <p>位置: X: 0, Y: 0, Z: 5</p>
    </div>

    <!-- スライダーの表示 -->
    <div id="speedControl" class="control-box">
        <label for="speedSlider">移動スピード:</label>
        <input type="range" id="speedSlider" min="0.01" max="1" step="0.01" value="0.1">
    </div>

    <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
    <script>
        // シーン、カメラ、レンダラーの初期化
        const scene = new THREE.Scene();
        // ↓ カメラの設定。 視野角, アスペクト比, 近距離クリッピング, 遠距離クリッピング(それより近い(遠い)ものは描画されない)
        const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); 
        const renderer = new THREE.WebGLRenderer();
        // レンダラー(描画エンジン)のサイズをブラウザウィンドウの幅と高さにする
        renderer.setSize(window.innerWidth, window.innerHeight); 
        // レンダラーをHTMLに追加
        document.body.appendChild(renderer.domElement);

        // 背景色の設定
        scene.background = new THREE.Color(0x87CEEB); // 空色

        // ライトの設定
        const light = new THREE.DirectionalLight(0xffffff, 2);  // 光の色, 光の強さ
        light.position.set(10, 10, 10); // 光源の位置
        scene.add(light); // 光源をシーンに追加

        // 立方体1（光沢あり）
        const cube1 = new THREE.Mesh(
            new THREE.BoxGeometry(2, 1, 1),
            new THREE.MeshStandardMaterial({ 
                color: 0x00ff00,   // 緑色
                roughness: 0.2,    // 少し光沢がある
                metalness: 0.5     // 半金属
            })
        );
        cube1.position.set(-2, 0.5, 0);  // y座標を0.5に設定（地面から浮かせる）
        scene.add(cube1);

        // 立方体2（光沢なし）
        const cube2 = new THREE.Mesh(
            new THREE.BoxGeometry(1, 2, 1),
            new THREE.MeshStandardMaterial({ 
                color: 0xff0000,   // 赤色
                roughness: 1,      // 光沢なし
                metalness: 0       // 非金属
            })
        );
        cube2.position.set(0, 0.5, 0);    // y座標を0.5に設定
        scene.add(cube2);

        // 立方体3（光沢強）
        const cube3 = new THREE.Mesh(
            new THREE.BoxGeometry(1, 1, 2),
            new THREE.MeshStandardMaterial({ 
                color: 0x0000ff,   // 青色
                roughness: 0.05,   // 非常に光沢が強い
                metalness: 1       // 金属的な質感
            })
        );
        cube3.position.set(2, 0.5, 0);    // y座標を0.5に設定
        scene.add(cube3);

        // 立方体(半透明)
        const cube4 = new THREE.Mesh(
            new THREE.BoxGeometry(2, 2, 1),
            new THREE.MeshStandardMaterial({
                color: 0x00ff00,
                opacity: 0.5,  // 50%透明
                transparent: true  // 透明にするための設定
            })
        );
        cube4.position.set(-3, 0.5, -4);   
        scene.add(cube4);

        // 立方体(発光)
        const cube5 = new THREE.Mesh(
            new THREE.BoxGeometry(1, 1, 1),
            new THREE.MeshStandardMaterial({
                color: 0x00ff00,
                emissive: 0x00ff00,  // 緑色の発光を設定
                emissiveIntensity: 5  // 発光の強さ
            })
        );
        cube5.position.set(2, 2, -6);    // y座標を0.5に設定
        scene.add(cube5);

        // 床の作成
        const floor = new THREE.Mesh(
            new THREE.PlaneGeometry(500, 500), // 500 * 500 の平面
            new THREE.MeshBasicMaterial({ color: 0x2e8b57, side: THREE.DoubleSide })); // 平面の両面を描画
        floor.rotation.x = -Math.PI / 2; // 90度回転(そのままだと縦に描画される)
        scene.add(floor);
        // グリッド 全体のサイズ, 1マスの大きさ(分割数), グリッドの線の色
        scene.add(new THREE.GridHelper(500, 50, 0xFFFFFF, 0xFFFFFF)); 

        camera.position.z = 5;

        // マウス操作に関するフラグと変数
        let isMousePressed = false           // マウスが押されているかどうか
        let lastMouseX = 0, lastMouseY = 0;  // マウスの最後の位置を記録(これで移動量を出す)
        let rotationX = 0, rotationY = 0;    // 回転かくどを管理
        // 進行方向の管理(キーを押されたらtrue,離れたらfalseにする)
        const moveDirection = { forward: false, backward: false, left: false, right: false }; 
        let moveSpeed = 0.1; // 動く速さ(スライダーで調節される)

        // スライダーの値を取得して移動速度を更新
        document.getElementById('speedSlider').addEventListener('input', (event) => {
            moveSpeed = parseFloat(event.target.value);
        });

        // マウス操作
        // クリックしたとき(押し始め)
        document.addEventListener('mousedown', (event) => { 
            isMousePressed = true;           // マウスが置かれている状態を記録
            lastMouseX = event.clientX;      // マウスが押された時点でのx座標を記録
            lastMouseY = event.clientY;      // マウスが押された時点でのy座標を記録
        });  
        // クリックが外れた時(押し終わり)
        document.addEventListener('mouseup', () => {
             isMousePressed = false; 
            });
        // マウスを移動させたときに発火(if (isMousePressed)によってクリック中のみになる)
        document.addEventListener('mousemove', (event) => {
            if (isMousePressed) {
                rotationX += (event.clientX - lastMouseX) * 0.005;  // 現在-直前でマウスの移動量を計算
                rotationY -= (event.clientY - lastMouseY) * 0.005;  // 0.005の部分は回転感度の調整の係数
                camera.rotation.set(rotationY, rotationX, 0);  // x(上下),y(左右),z(傾き)
                lastMouseX = event.clientX;   // 現在のマウスの座標を記録
                lastMouseY = event.clientY;   
            }
        });



        // キーボード操作
        document.addEventListener('keydown', (event) => {
            if (event.key === 'w') moveDirection.forward = true;
            if (event.key === 's') moveDirection.backward = true;
            if (event.key === 'a') moveDirection.left = true;
            if (event.key === 'd') moveDirection.right = true;
            if (event.key === 'q') { rotationY = 0; camera.rotation.x = 0; } // カメラリセット
        });

        document.addEventListener('keyup', (event) => {
            if (event.key === 'w') moveDirection.forward = false;
            if (event.key === 's') moveDirection.backward = false;
            if (event.key === 'a') moveDirection.left = false;
            if (event.key === 'd') moveDirection.right = false;
        });



        // アニメーション
        function animate() {
            requestAnimationFrame(animate);   // 次のフレームをリクエスト

            const direction = new THREE.Vector3();
            camera.getWorldDirection(direction);    // カメラが向いている方向を取得
            direction.y = 0;        // Y軸(上下)方向の移動を制限
            direction.normalize();  // 正規化(ベクトルの長さを1にする) 

            // 前後移動
            if (moveDirection.forward) camera.position.addScaledVector(direction, moveSpeed);
            if (moveDirection.backward) camera.position.addScaledVector(direction, -moveSpeed);

            const right = new THREE.Vector3();  
            camera.getWorldDirection(right);           // カメラの向いている向きを取得
            right.cross(new THREE.Vector3(0, 1, 0));   // カメラの右方向を計算
            right.normalize();

            // 左右移動
            if (moveDirection.left) camera.position.addScaledVector(right, -moveSpeed);
            if (moveDirection.right) camera.position.addScaledVector(right, moveSpeed);

            camera.position.y = 1; // 高さ固定

            // カメラ座標の表示更新
            document.getElementById('coordinates').innerHTML = `<p>位置: X: ${camera.position.x.toFixed(2)}, Y: ${camera.position.y.toFixed(2)}, Z: ${camera.position.z.toFixed(2)}</p>`;

            renderer.render(scene, camera);
        }

        // アニメーションの開始
        animate();


        // ウィンドウサイズ変更対応
        window.addEventListener('resize', () => {
            camera.aspect = window.innerWidth / window.innerHeight;  // アスペクト比の更新
            camera.updateProjectionMatrix();   // 投影行列の更新（カメラの視覚的な変換を表現する行列）
            renderer.setSize(window.innerWidth, window.innerHeight); // レンダラーサイズの更新
        });

    </script>
</body>

</html>


```
</details>

### シーンとカメラのセットアップ
最初に、THREE.SceneとTHREE.PerspectiveCameraを使って3D空間を作成し、カメラを配置。

```javascript
        // シーン、カメラ、レンダラーの初期化
        const scene = new THREE.Scene();
        // ↓ カメラの設定。 視野角, アスペクト比, 近距離クリッピング, 遠距離クリッピング(それより近い(遠い)ものは描画されない)
        const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); 
```

### レンダラーの設定
次に、THREE.WebGLRendererを使って、ブラウザ上にシーンを描画。レンダラーは、シーンとカメラの情報を元にブラウザに3Dグラフィックを描画する役割を果たす。

```javascript
        const renderer = new THREE.WebGLRenderer();
        // レンダラー(描画エンジン)のサイズをブラウザウィンドウの幅と高さにする
        renderer.setSize(window.innerWidth, window.innerHeight); 
        // レンダラーをHTMLに追加
        document.body.appendChild(renderer.domElement);
```

### 背景色の設定とライトの設定
光の当たっている面が明るく、当たっていない面が暗くなる。

```javascript
        // 背景色の設定
        scene.background = new THREE.Color(0x87CEEB); // 空色

        // ライトの設定
        const light = new THREE.DirectionalLight(0xffffff, 2);  // 光の色, 光の強さ
        light.position.set(10, 10, 10); // 光源の位置
        scene.add(light); // 光源をシーンに追加

```


### 立方体の作成
THREE.BoxGeometryを使って、いくつかの立方体を作成。半透明とか光沢とか発光とか。光沢についてはよくわからなかった。

<details><summary>立方体のコード</summary>

```javascript

        // 立方体1（光沢あり）
        const cube1 = new THREE.Mesh(
            new THREE.BoxGeometry(2, 1, 1),
            new THREE.MeshStandardMaterial({ 
                color: 0x00ff00,   // 緑色
                roughness: 0.2,    // 少し光沢がある
                metalness: 0.5     // 半金属
            })
        );
        cube1.position.set(-2, 0.5, 0);  // y座標を0.5に設定（地面から浮かせる）
        scene.add(cube1);

        // 立方体2（光沢なし）
        const cube2 = new THREE.Mesh(
            new THREE.BoxGeometry(1, 2, 1),
            new THREE.MeshStandardMaterial({ 
                color: 0xff0000,   // 赤色
                roughness: 1,      // 光沢なし
                metalness: 0       // 非金属
            })
        );
        cube2.position.set(0, 0.5, 0);    // y座標を0.5に設定
        scene.add(cube2);

        // 立方体3（光沢強）
        const cube3 = new THREE.Mesh(
            new THREE.BoxGeometry(1, 1, 2),
            new THREE.MeshStandardMaterial({ 
                color: 0x0000ff,   // 青色
                roughness: 0.05,   // 非常に光沢が強い
                metalness: 1       // 金属的な質感
            })
        );
        cube3.position.set(2, 0.5, 0);    // y座標を0.5に設定
        scene.add(cube3);

        // 立方体(半透明)
        const cube4 = new THREE.Mesh(
            new THREE.BoxGeometry(2, 2, 1),
            new THREE.MeshStandardMaterial({
                color: 0x00ff00,
                opacity: 0.5,  // 50%透明
                transparent: true  // 透明にするための設定
            })
        );
        cube4.position.set(-3, 0.5, -4);   
        scene.add(cube4);

        // 立方体(発光)
        const cube5 = new THREE.Mesh(
            new THREE.BoxGeometry(1, 1, 1),
            new THREE.MeshStandardMaterial({
                color: 0x00ff00,
                emissive: 0x00ff00,  // 緑色の発光を設定
                emissiveIntensity: 5  // 発光の強さ
            })
        );
        cube5.position.set(2, 2, -6);    // y座標を0.5に設定
        scene.add(cube5);

```
</details>


### 床とグリッドの追加
床の作成。3D空間内での位置を確認しやすくするため、GridHelperを使ってグリッド模様を追加。回転させないと床にならないことに注意。

```javascript
        const floor = new THREE.Mesh(
            new THREE.PlaneGeometry(500, 500), // 500 * 500 の平面
            new THREE.MeshBasicMaterial({ color: 0x2e8b57, side: THREE.DoubleSide })); // 平面の両面を描画
        floor.rotation.x = -Math.PI / 2; // 90度回転(そのままだと縦に描画される)
        scene.add(floor);
        // グリッド 全体のサイズ, 1マスの大きさ(分割数), グリッドの線の色
        scene.add(new THREE.GridHelper(500, 50, 0xFFFFFF, 0xFFFFFF)); 

```

### カメラ操作
マウス操作でカメラを回転させたり、WASDキーで移動させたり、Qキーでカメラの向きをリセットできるようにした。

```javascript
        // マウス操作に関するフラグと変数
        let isMousePressed = false           // マウスが押されているかどうか
        let lastMouseX = 0, lastMouseY = 0;  // マウスの最後の位置を記録(これで移動量を出す)
        let rotationX = 0, rotationY = 0;    // 回転かくどを管理
        // 進行方向の管理(キーを押されたらtrue,離れたらfalseにする)
        const moveDirection = { forward: false, backward: false, left: false, right: false }; 
        let moveSpeed = 0.1; // 動く速さ(スライダーで調節される)
```

### 移動速度の調節
移動速度をスライダーで変更できるようにした。これで世界の端までいける。

```javascript
        // スライダーの値を取得して移動速度を更新
        document.getElementById('speedSlider').addEventListener('input', (event) => {
            moveSpeed = parseFloat(event.target.value);
        });
```

### マウス操作(カメラ操作)
マウスでカメラ操作。クリックを管理して、クリック中のみ動くようにした。


```javascript
        // クリックしたとき(押し始め)
        document.addEventListener('mousedown', (event) => { 
            isMousePressed = true;           // マウスが置かれている状態を記録
            lastMouseX = event.clientX;      // マウスが押された時点でのx座標を記録
            lastMouseY = event.clientY;      // マウスが押された時点でのy座標を記録
        });  
        // クリックが外れた時(押し終わり)
        document.addEventListener('mouseup', () => {
             isMousePressed = false; 
            });
        // マウスを移動させたときに発火(if (isMousePressed)によってクリック中のみになる)
        document.addEventListener('mousemove', (event) => {
            if (isMousePressed) {
                rotationX += (event.clientX - lastMouseX) * 0.005;  // 現在-直前でマウスの移動量を計算
                rotationY -= (event.clientY - lastMouseY) * 0.005;  // 0.005の部分は回転感度の調整の係数
                camera.rotation.set(rotationY, rotationX, 0);  // x(上下),y(左右),z(傾き)
                lastMouseX = event.clientX;   // 現在のマウスの座標を記録
                lastMouseY = event.clientY;   
            }
        });
```

### キーボード操作(移動)
wasdで移動。

```javascript
        // キーボード操作
        document.addEventListener('keydown', (event) => {
            if (event.key === 'w') moveDirection.forward = true;
            if (event.key === 's') moveDirection.backward = true;
            if (event.key === 'a') moveDirection.left = true;
            if (event.key === 'd') moveDirection.right = true;
            if (event.key === 'q') { rotationY = 0; camera.rotation.x = 0; } // カメラリセット
        });

        document.addEventListener('keyup', (event) => {
            if (event.key === 'w') moveDirection.forward = false;
            if (event.key === 's') moveDirection.backward = false;
            if (event.key === 'a') moveDirection.left = false;
            if (event.key === 'd') moveDirection.right = false;
        });
```

### アニメーション
画面の更新。

```javascript
        // アニメーション
        function animate() {
            requestAnimationFrame(animate);   // 次のフレームをリクエスト

            const direction = new THREE.Vector3();
            camera.getWorldDirection(direction);    // カメラが向いている方向を取得
            direction.y = 0;        // Y軸(上下)方向の移動を制限
            direction.normalize();  // 正規化(ベクトルの長さを1にする) 

            // 前後移動
            if (moveDirection.forward) camera.position.addScaledVector(direction, moveSpeed);
            if (moveDirection.backward) camera.position.addScaledVector(direction, -moveSpeed);

            const right = new THREE.Vector3();  
            camera.getWorldDirection(right);           // カメラの向いている向きを取得
            right.cross(new THREE.Vector3(0, 1, 0));   // カメラの右方向を計算
            right.normalize();

            // 左右移動
            if (moveDirection.left) camera.position.addScaledVector(right, -moveSpeed);
            if (moveDirection.right) camera.position.addScaledVector(right, moveSpeed);

            camera.position.y = 1; // 高さ固定

            // カメラ座標の表示更新
            document.getElementById('coordinates').innerHTML = `<p>位置: X: ${camera.position.x.toFixed(2)}, Y: ${camera.position.y.toFixed(2)}, Z: ${camera.position.z.toFixed(2)}</p>`;

            renderer.render(scene, camera);
        }

        // アニメーションの開始
        animate();
```



### 画面サイズの変更への対応
ウィンドウのサイズが変わったときに、カメラのアスペクト比を更新。

```javascript
        // ウィンドウサイズ変更対応
        window.addEventListener('resize', () => {
            camera.aspect = window.innerWidth / window.innerHeight;  // アスペクト比の更新
            camera.updateProjectionMatrix();   // 投影行列の更新（カメラの視覚的な変換を表現する行列）
            renderer.setSize(window.innerWidth, window.innerHeight); // レンダラーサイズの更新
        });
```



## おわりに
WebGLとThree.jsを使うことで、ブラウザ上で簡単に3Dオブジェクトを描画し、インタラクティブなアプリケーションを作ることができました。

まだ単に空間を作って動けるようにしただけなので、他の3Dオブジェクトを追加したり、何かしらのアニメーションやエフェクトを加えたり、ちょっとしたミニゲームにしたり、いろいろやってみたいです。