Three.jsで、チームラボを再現ー完成品とソースコード公開

# はじめに
初投稿！チームラボが作り出す空間って素敵ですよね！
せっかくなら新しい技術に触れて表現したいと思いました！私の好きはこれです😁
**是非「いいね」お願いします。励みになります。**

<iframe width="560" height="315" src="https://www.youtube.com/embed/ZCFsCHLGFlM?si=ndPM6do-4OGRGFHX" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

**私の完成品はこちら!**
<iframe width="560" height="315" src="https://www.youtube.com/embed/cOJXT9kQUMo?si=buIc6Xhe1PO7ZoU1&amp;start=5" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

# 開発工程
## 実際の植物を観察
1. 実際に植物園に行き、写真撮影
2. 好きな花を20種、草を８種類ピックアップ

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1089860/2d8b3fad-56d0-812b-ff4b-cb610feffba5.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1089860/d01e8c79-9d0d-dd9c-9f2c-adce6dc937cc.png)

## まずは簡単に作る。
### 基本のセットアップ

```javascript
// 必要なモジュールをインポート
import * as THREE from 'three';

// シーン、カメラ、レンダラーを初期化
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
    75, // 視野角 (FOV)
    window.innerWidth / window.innerHeight, // アスペクト比
    0.1, // 近距離クリップ
    1000 // 遠距離クリップ
);

const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight); // レンダラーのサイズを設定
document.body.appendChild(renderer.domElement); // レンダラーの DOM を追加

// カメラの初期位置を設定
camera.position.z = 10; // カメラを遠ざける位置
```

### 作り方を学ぶ。
```javascript
const groundGeometry = new THREE.PlaneGeometry(50, 50);
const groundMaterial = new THREE.MeshBasicMaterial({ color: 0x228B22 }); 
const ground = new THREE.Mesh(groundGeometry, groundMaterial);
ground.rotation.x = -Math.PI / 2; // 平面を横に倒す
scene.add(ground);

const loader = new THREE.TextureLoader();

const createPlant = (texturePath, x, y, z) => {
    loader.load(texturePath, (texture) => {
        const planeGeometry = new THREE.PlaneGeometry(1, 1); // サイズは調整可能
        const planeMaterial = new THREE.MeshBasicMaterial({
            map: texture,
            transparent: true, // 透過PNGをサポート
        });
        const plant = new THREE.Mesh(planeGeometry, planeMaterial);
        plant.position.set(x, y, z);
        scene.add(plant);
    });
};

// 複数の草花を配置
createPlant('flower1.png', 0, 0.5, 0);
createPlant('flower2.png', 2, 0.5, -1);
createPlant('flower3.png', -2, 0.5, 1);
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1089860/e58b29c5-242f-a366-bc27-d57af4bae02f.png)
うっすらしか見えてないけど、ここから改良していきます！

:::note info
うっすらしか見えてないけど、改良していこう!
:::

## 上下左右を植物で囲む
### 植物の配置をランダムにして地面作成.
textures.Texturesには、描写する画像のURLが入っている。
座標にランダム要素を入れて、前後（startZ,endZ) に描写範囲を広げる。描写する植物もランダムに選ばれるようにしている。
```javascript
function createGround(startZ, endZ) {
  // x 軸と z 軸に沿ってスプライトを生成
  for (let x = -40; x < 40; x += 2) {
    for (let z = startZ; z < endZ; z += 5) {
      // ランダムにテクスチャを選択
      const textureUrl = textures.Textures[Math.floor(Math.random() * textures.topTextures.length)];

      // キャッシュからテクスチャを取得
      const cachedTexture = textureCache.get(textureUrl);

      if (cachedTexture) {
        // スプライトマテリアルを作成
        const material = new THREE.SpriteMaterial({ map: cachedTexture });

        // スプライトを作成し、ランダムな位置に配置
        const sprite = new THREE.Sprite(material);
        sprite.position.set(
          x + random_1 + 5, // x 座標にランダムオフセットを追加
          Math.random() * 5 - 20, // y 座標をランダムに設定
          z + random_1 // z 座標にランダムオフセットを追加
        );

        // シーンにスプライトを追加
        scene.add(sprite);
      }
    }
  }
}
```
### アーチ型の天井を作成
x座標を少し工夫。アーチの形に合わせて画像に傾きをつけるためベースroationOffsetを設定しています。
~~~ javascript
function createTop(startZ, endZ) {
  // x 軸と z 軸に沿ってスプライトを生成
  for (let x = -12; x < 80; x += Math.random() * 5) { // x 軸のランダムな間隔
    for (let z = startZ; z < endZ; z += Math.random() * 5) { // z 軸のランダムな間隔
      // ランダムにテクスチャを選択
      const textureUrl = textures.groundTextures[
        Math.floor(Math.random() * textures.groundTextures.length)
      ];

      // キャッシュからテクスチャを取得
      const cachedTexture = textureCache.get(textureUrl);

      if (cachedTexture) {
        // スプライトマテリアルを作成
        const material = new THREE.SpriteMaterial({ map: cachedTexture });

        // スプライトを作成
        const sprite = new THREE.Sprite(material);

        // スプライトの位置を設定
        sprite.position.set(
          x + random_1 - 35, // x 座標をランダムにオフセット
          10 + 10 * Math.sin((x / 70) * Math.PI) + Math.random() * 5, // sin 関数で高さを調整
          z + random_1 // z 座標をランダムにオフセット
        );

        // スプライトの回転を設定
        const baseRotation = Math.PI - 5.5; // 基本回転角
        const rotationOffset = (Math.random() - 0.5) * Math.PI / 6; // ランダムな回転オフセット
        sprite.material.rotation = baseRotation + rotationOffset;

        // シーンにスプライトを追加
        scene.add(sprite);
      }
    }
  }
}

~~~
### 左右側面の作成
左側を作成。右側も座標を変更すれば作成可能です。let xの幅はいい加減です。

~~~ javascript
function createGround_left(startZ, endZ) {
    for (let x = - 20; x < -5 ; x += 2) {
        for (let z = startZ; z < endZ; z += 2) {
            const textureUrl = textures.groundTextures4[Math.floor(Math.random() * textures.groundTextures4.length)];
            const cachedTexture = textureCache.get(textureUrl);
            if (cachedTexture) {
                const material = new THREE.SpriteMaterial({ map: cachedTexture });
                const sprite = new THREE.Sprite(material);
                const posX = x + random_1 - 20;
                const posY = - x + Math.random() * 30 - 30;
                const posZ = z + random_1;
                sprite.position.set(posX, posY, posZ);
                sprite.scale.set(scale, scale, 1);
                const maxTilt = - Math.PI / 4 + (posY + 40) / 100;
                const minTilt = - Math.PI / 8 + (posY + 40) / 200;
                sprite.material.rotation = Math.random() * (maxTilt - minTilt) + minTilt + 25 ;
                scene.add(sprite);
}}}}
~~~

### 現状はこんな感じ
:::note alert
絵を見てるとの同じで、少し残念...
:::
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1089860/7fc29afa-9e3e-e06e-c6f3-e81faa2ff086.png)

## ユーザー操作を実装
:::note info
ここからは、ユーザーの動きに合わせて画面を変化させます。
:::
画像を大きくすることで、植物が成長させる。
前回作った４方向の植物は、それぞれ配列に格納されている。
大きさの上限を決めて、ランダムな倍率で大きくする。

~~~ javascript
// 個別の花を成長させる関数
function growFlowers(flowers, maxScale) {
  flowers.forEach(flower => {
    const growth = Math.random() * 0.05 + 0.6; // ランダムな成長率 (0.6 ~ 0.65)
    if (flower.scale.x < maxScale) { // 最大スケールに達するまで成長
      flower.scale.set(
        flower.scale.x + growth, // x 軸のスケールを更新
        flower.scale.y + growth  // y 軸のスケールを更新
      );
    }
  });
}
~~~

### ユーザー操作を感知して、先ほど作った関数に繋げる。
マウス操作で、カメラの視点を動かす。
はじめの４行で上下（Y）左右（X)の限界を決め、後ろを振り返ることができないようにしている。タッチの開始地点と終了地点を利用。

~~~ javascript
const maxYRotation = 1.9; 
const minYRotation = -1.9; 
const maxXRotation = Math.PI / 4;
const minXRotation = -Math.PI / 4;

function onTouchMove(event) {
  if (event.touches.length === 1) {
      const touch = event.touches[0];
      
      if (lastTouchX !== null && lastTouchY !== null) {
          const deltaX = (touch.clientX - lastTouchX) * 0.005;
          const deltaY = (touch.clientY - lastTouchY) * 0.005;
          camera.rotation.y += deltaX;
          camera.rotation.y = Math.max(minYRotation, Math.min(maxYRotation, camera.rotation.y));
          camera.rotation.x += deltaY;
          camera.rotation.x = Math.max(minXRotation, Math.min(maxXRotation, camera.rotation.x));
          growAllFlowers();
      }
      lastTouchX = touch.clientX;
      lastTouchY = touch.clientY;
}}
function onTouchEnd() {
  lastTouchX = null;
  lastTouchY = null;
}
document.addEventListener("touchmove", onTouchMove, { passive: false });
document.addEventListener("touchend", onTouchEnd);
document.addEventListener("touchcancel", onTouchEnd);
~~~

### 画面をクリックした時に画像を拡大させる。

~~~ javascript
window.addEventListener('click', () => growAllFlowers());
~~~
### マウス移動で視点操作 

~~~ javascript
window.addEventListener("mousemove", (event) => {
    const mouseX = (event.clientX / window.innerWidth) * 2 - 1;
    const mouseY = - (event.clientY / window.innerHeight) * 2 + 1;
    camera.rotation.y = - mouseX * 0.5;
    camera.rotation.x =  mouseY * 0.2;
    growAllFlowers();
});
~~~

### スクロールした時に、カメラのZ座標を変化させる。手前/奥に移動。

~~~ javascript
window.addEventListener("wheel", (event) => {
    scrollSpeed -= event.deltaY * 0.05; //慣性を設定。
    scrollSpeed = Math.max(MIN_SCROLL_SPEED, Math.min(MAX_SCROLL_SPEED, scrollSpeed));
    growAllFlowers();
});
~~~

### (おまけ） 画面のサイズが変更された時に、再計算を行う。
~~~ javascript
window.addEventListener('resize', () => {
    camera.aspect = window.innerWidth / window.innerHeight;
    camera.updateProjectionMatrix();
    renderer.setSize(window.innerWidth, window.innerHeight);
});
~~~
## 一休み！！
<iframe width="560" height="315" src="https://www.youtube.com/embed/bJATqXobUy0?si=_qwf6LmK2fJcXaKw" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

#  問題点

**これらを解決します！**

:::note warn
- 描写した画像が配列に残り続けるため、時間が経つごとに重い
- 最初の画像読み込みに時間がかかる
:::

## 前回の問題点
-  描写した画像が配列に残り続けるため、時間が経つごとに重い
-  最初の画像読み込みに時間がかかる

### Before

![スクリーンショット 2024-12-19 1.25.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1089860/4019d627-d50e-3e32-97af-748784ded916.png)

2017年のGoogle調査によると、
・1秒から3秒になると直帰率が 32% 増加
・6秒になると 106% 増加
・10秒まで遅くなると 123% 増加
と報告されています。

参考URL: Think with Google

https://www.thinkwithgoogle.com/marketing-strategies/app-and-mobile/mobile-page-speed-new-industry-benchmarks/

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1089860/5fdd54a4-e024-1980-4cac-0e14ef152a38.png)


## 解決策
ネットにあるものは全部試す！

https://coosy.co.jp/blog/website-lightening/

https://baigie.me/officialblog/2019/11/26/high-speed-frontend/

その上で効果的だったものをリストアップすると以下の通りでした。

- [ ] 読み込みファイルを軽くする（*jpg/png→webp*）
- [ ] 事前に画像をロードし、キャッシュを利用する
- [ ] CDNサーバーの利用（*元々は、Dropboxを利用していた。Googledriveよりは短い。*）


~~~
//元の共有リンク
"https://www.dropbox.com/scl/fi/3d669v6sq6zmz5r1k92sj/DALL-E-2024-11-16-07.10.jpg?rlkey=bt28f0kikn02j08du3gztmo8z&st=41sil8py&dl=0 "
// コード
<img src="https://dl.dropboxusercontent.com/scl/fi/3d669v6sq6zmz5r1k92sj/DALL-E-2024-11-16-07.10.jpg?rlkey=bt28f0kikn02j08du3gztmo8z"alt="Image 1" style="width: 150px; height: 150px; border-radius: 50%; border: 4px solid #ddd;">
~~~
:::note warn
元の共有リンクから、
1.  "www.dropbox.com"を、"dl.dropboxusercontent.com"に変更
1.  &st以降の文字列の削除。今回の場合は、"&st=41sil8py&dl=0"
:::

https://spicaweblog.com/2022/05/codepen-img/

##  CDNとは？
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1089860/a0285096-8c06-4114-efd3-141e7aa3306b.png)

https://www.kagoya.jp/howto/it-glossary/web/cdn/

今回利用したのは、これ。

https://www.jsdelivr.com/


## コードを見る
### .webpの利用
~~~javascript

const textures = {
 groundTextures : [
    ".../flower/15.webp",
    ],
    groundTextures2:[
    ".../flower/17.webp",
    ]
    ...
    }
~~~

### 表示領域をカメラの位置に合わせて、動的に変化させる

~~~javascript
// camera.position.zは、カメラの現在位置の z 座標を取得します。
// カメラの位置変化を検出するために使います。

// shiftThresholdは、カメラが動いたとみなす「最小移動量」。(1で設定）
// これを超える変化があった場合に、次の処理を実行します。
const cameraZ = camera.position.z;
if (Math.abs(cameraZ - lastCameraZ) > shiftThreshold) {
    //正の値の場合、カメラは前方に移動。
    //負の値の場合、カメラは後方に移動。
    const shiftAmount = cameraZ - lastCameraZ;

    //描画対象の範囲を表すオブジェクトを更新する。
    renderRange.zMin += shiftAmount;
    renderRange.zMax += shiftAmount;

    // カメラが前方に移動した場合（shiftAmount > 0）:
    // 描画範囲の後方（zMax）に新しいオブジェクトを生成します。
    if (shiftAmount > 0) {
    createtopGround(renderRange.zMax - shiftAmount, renderRange.zMax);
    createGround(renderRange.zMax - shiftAmount, renderRange.zMax);
    createGround_right(renderRange.zMax - shiftAmount, renderRange.zMax);
    createGround_left(renderRange.zMax - shiftAmount, renderRange.zMax);
    }
    // カメラが後方に移動した場合（shiftAmount <= 0）:
    // 描画範囲の前方（zMin）に新しいオブジェクトを生成します。
    else {
    createtopGround(renderRange.zMin, renderRange.zMin - shiftAmount);
    createGround(renderRange.zMin, renderRange.zMin - shiftAmount);
    createGround_left(renderRange.zMin, renderRange.zMin - shiftAmount);
    createGround_right(renderRange.zMin, renderRange.zMin - shiftAmount);
    }
~~~

:::note warn
警告
ここで、花も追加していません。理由は、ユーザー操作と全ての植物が連携すると少し不自然になります。具体的には、画面の奥から湧いてくるような印象になり咲いているように見えなかった。
:::

### 領域外の画像を取り除く
指定された範囲（zMin と zMax）の外にあるオブジェクトをシーンから削除し、対応するリストからも取り除く処理を行います。
~~~javascript
function removeObjectsOutOfRange(objectsList, zMin, zMax) {
    for (let i = objectsList.length - 1; i >= 0; i--) {
        const object = objectsList[i];
        if (object.position.z < zMin || object.position.z > zMax) {
            scene.remove(object);
            objectsList.splice(i, 1);
        }}}
function removeFlowersOutOfRange(zMin, zMax) {
    const objectLists = [growingFlowers, growingFlowers2,growingFlowers3,growingFlowers4];
    objectLists.forEach(list => removeObjectsOutOfRange(list, zMin, zMax));
}
~~~
オブジェクトの z 座標が zMin より小さいか、zMax より大きい場合は範囲外とみなします。
- シーンから削除: scene.remove(object)
- リストから削除: objectsList.splice(i, 1)
~~~ javascript
for (let i = objectsList.length - 1; i >= 0; i--):
~~~
:::note warn
リストを逆順にループします。これにより、要素を削除してもインデックスのずれが生じないようにします。
:::

:::note info
ゲームでプレイヤーが進む方向にだけ新しい地形を生成するような場面利用されています。
- 位置追跡と変化量の利用
カメラの位置変化を検出し、その結果に応じて処理を実行。
- 描画範囲の管理
動的に変化する描画範囲を基に、生成対象を決定。
:::


## ローディング画面で動作の安定を確認しよう。
1. ストレス軽減: ローディング画面が滑らかに動作することで、ユーザーはアプリやウェブサイトが「止まった」や「不安定」と感じないように
1. バックエンドで行われている処理が適切に機能しているかをテストするのに役立つ。
1. 初期のローディングで不具合があると、ユーザーはそのアプリやサイトを再利用する可能性が低下する。

### テクスチャーの読み込み状況をユーザーに伝える
進捗バーだけよりも、部分的な要素を表示することで、ユーザーに即座に視覚的FBを提供したい。
~~~ javascript
//画像の読み込みを管理し、進捗状況などを追跡するためのオブジェクト
const loader = new THREE.TextureLoader(manager);
const startTime = Date.now();

//進捗状況の計算と表示
//経過時間 elapsedTime を計算。
//全体の見積もり時間 estimatedTotalTime を計算。
//残り時間 remainingTime を算出。
manager.onProgress = (url, itemsLoaded, itemsTotal) => {
    const progress = (itemsLoaded / itemsTotal) * 100;
    document.getElementById("progress-bar").style.width = `${progress}%`;
    const elapsedTime = (Date.now() - startTime) / 1000;
    const estimatedTotalTime = (elapsedTime / itemsLoaded) * itemsTotal;
    const remainingTime = Math.max(0, estimatedTotalTime - elapsedTime);
    document.getElementById("remaining-time").textContent = 
    
    //ここに画面に表示する文字を表す。urlを追加すれば、今何を読み込んでいるかがわかる。
        `(${progress.toFixed(2)}%, ${remainingTime.toFixed(2)}s left)`;
};

//ロード完了時の処理
manager.onLoad = () => {
    checkAnimationStability();//アニメーションの安定を判断。後ほど記述
};
~~~

:::note info
非同期処理を使って複数のテクスチャを並列でロードします。
:::
~~~javascript 
//テクスチャーの読み込み
function loadTexture(url) {
    return new Promise((resolve, reject) => {
        loader.load(
            url,
            (texture) => {
                textureCache.set(url, texture);
                resolve(texture);
            },
            undefined,
            (err) => {
                reject(err);
            });
    });
}

// textures: すべてのテクスチャURLを配列形式にまとめています。
// Promise.all: 全ての loadTexture を同時に実行。
// manager.onLoad: 全てのテクスチャがロード完了後に実行。

(async () => {
    const allUrls = Object.values(textures).flat();
    await Promise.all(allUrls.map(loadTexture));
    manager.onLoad();
})();
~~~

### 安定性を確認するロジック
レームレートを確認し、アニメーションの安定性を評価します。
~~~javascript
//アニメーションが安定したかどうかを記録するフラグ
let isAnimationStable = false;

function stabilityChecker() {
    if (isAnimationStable) return;
    const fps = Math.random() * 10 + 30;
    if (fps >= 30) { 
        stabilityCheckCount++;
        if (stabilityCheckCount >= stabilityThreshold) {
            isAnimationStable = true;
            endLoadingScreen();
        }
    } else stabilityCheckCount = 0;
    requestAnimationFrame(stabilityChecker);
}
~~~
判定ロジックは、
1. fps >= 30（十分なフレームレート）
stabilityCheckCount を増加。連続して stabilityThreshold（10回）以上安定したら、isAnimationStable を true に設定し、endLoadingScreen を呼び出します。

2. fps < 30（低フレームレート）の場合、カウントをリセット

### ローディング画面の終了処理
endLoadingScreen: ローディング画面を非表示にし、メインコンテンツを表示します。
~~~ javascript
function endLoadingScreen() {
    document.getElementById("loading-screen").style.display = "none";
    document.getElementById("main-content").style.display = "block";
    animate();
}
~~~
# まずは、動作確認！
![スクリーンショット 2024-12-19 0.52.10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1089860/3cd171dd-34c9-e4a1-63fd-711fd7cb986d.png)

![スクリーンショット 2024-12-19 0.52.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1089860/4407f56e-53a8-23f2-1038-d3af69201239.png)

![スクリーンショット 2024-12-19 0.53.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1089860/62a00fea-25be-89cf-b51b-6ae17459ac7e.png)

# 次に、速度検証!!
https://pagespeed.web.dev/

### Before

![スクリーンショット 2024-12-19 1.25.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1089860/4019d627-d50e-3e32-97af-748784ded916.png)

### After
改善できてる！
![スクリーンショット 2024-12-19 1.16.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1089860/7984ebab-c9e1-e85d-709d-eb519667e680.png)