【Three.js】1つのページに複数のThree.jsシーンを作成する

Three.jsでHTMLのbody内にシーンを追加する場合、`new THREE.Scene();`で新たにシーンを作成しますが、1つのbody内に複数のシーンを作成して、スクロールなどで切り替えたい、というようなケースもあります。その場合、HTMLで要素に`data属性`を指定して複数のシーンを描画する、という方法を紹介します。

デモは[こちら](https://codepen.io/outsider-kithy/pen/rNQZEZY)

## data属性とは
HTML5では、`<img src="photo.jpg" width="500" height="300">`の`src`や`width`,`height`のような、標準で定められている「属性」の他に、独自の属性を開発者が付加できる`data属性`というものがあります。これは、接頭辞に`data-`をつけて、開発者が自由に名前をつけることができます。

```html
<!-- div要素に`data-hoge`属性を追加 -->
<div data-hoge="piyo" id="fuga"> </div>
```
ここで指定した`data-hoge属性`は、JavaScriptで以下のようにして取得することができます。

```JavaScript
let elem = document.getElementById("fuga");
console.log(elem.dataset.hoge);
//　piyo と表示される
```

このdata属性を利用して、1つのbody要素の中に複数のThree.jsシーンを追加していきます。

[参考:データ属性の使用 - ウェブ開発を学ぶ | MDN](https://developer.mozilla.org/ja/docs/Learn/HTML/Howto/Use_data_attributes, 'データ属性の使用 - ウェブ開発を学ぶ | MDN')

## HTMLにシーンの数だけ要素を作成
まず、作成したいシーンの数だけHTML上に要素を作成します。
今回は、box,corn,octahedronの3つのメッシュをそれぞれ1つ描画する3つのシーンを作成します。
それぞれの要素に、`data-scene="box"`のようにdata属性を追加しておきます。

```html:index.html
<!--boxを描画するシーン-->
<div data-scene="box">
    <div class="wrap">
        <h2>Box</h2>
    </div>
</div>

<!--cornを描画するシーン-->
<div data-scene="corn">
    <div class="wrap">
        <h2>Corn</h2>
    </div>
</div>

<!--octahedronを描画するシーン-->
<div data-scene="Octahedron">
    <div class="wrap">
        <h2>Octahedron</h2>
    </div>
</div>
```

## JavaScriptで配列として各シーンを定義

### 1つだけのシーンを作成
次に、JavaScriptでThree.jsのシーンを定義していきます。
まずは、body要素に1つのシーンだけを作成し、Boxを1個だけ描画してみます。

```JavaScript
function init(){
    //レンダラー
    const renderer = new THREE.WebGLRenderer({
        antialias:true,
    });
    renderer.setSize(window.innerWidth, window.innerHeight);

    //data-scene属性を持つ要素にレンダラーを追加
    const elem = document.querySelector("[data-scene]");
    elem.appendChild(renderer.domElement);

    //シーン
    const scene = new THREE.Scene();
    scene.background = new THREE.Color(0x333333);

    //カメラ
    const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000);
    camera.position.set(0, 0, 10);
    camera.lookAt(0,0,0);
    scene.add(camera);

    //ライティング
    const ambientLight = new THREE.AmbientLight(0xffffff, 0.3);
    scene.add(ambientLight);

    const directionalLight = new THREE.DirectionalLight(0xffffff);
    camera.add(directionalLight);

    //Boxを1つだけシーンに追加
    const geometry = new THREE.BoxGeometry(2,2,2);
    const material = new THREE.MeshPhongMaterial({
        color: 0xff0000,
    });
    let mesh = new THREE.Mesh(geometry, material);
    scene.add(mesh);

    //アニメーション用の時間
    let time = new THREE.Clock();

    render();

    //毎フレーム実行されるレンダー関数
    function render(){
        requestAnimationFrame(render);
        renderer.render(scene, camera);
        mesh.rotation.x += Math.cos(time.getElapsedTime()) * 0.01;
        mesh.rotation.y += Math.sin(time.getElapsedTime()) * 0.01;
    }
}

//エントリーポイント
window.addEventListener("DOMContentLoaded", init);
```

Three.jsの基本的なコードですね。
ここから、複数のシーンを追加していきます。

### 複数のシーンを作成
data属性を利用して複数のシーンを描画する手順は、大まかに以下の流れになります。

1. シーンをレンダリングする1つのレンダラーを用意する
2. シーンを作成する関数`makeScene()`を作成する
3. 各シーンをオブジェクトの配列として定義し、`makeScene()`を実行する
4. HTMLから`data-scene属性`を持つ全ての要素を取得し、それぞれに各シーンを割り当てて行く
5. ブラウザの表示領域に入ったシーンだけを、レンダラーを使ってレンダリングする

まず、レンダラーを1つだけ用意します。
```JavaScript
const renderer = new THREE.WebGLRenderer({
    antialias:true,
});
```

次に、シーンを作成し戻り値としてsceneとcameraを返す関数`makeScene()`を定義します。
1つだけのシーンを描画する場合、sceneとcameraは1つずつあれば事足りますが、
今回は複数のシーンを描画するので、関数として定義しておいて、各シーン内の作成時に関数として利用できるようにしておきます。

```JavaScript
function makeScene(){
    const scene = new THREE.Scene();
    const camera = new THREE.PerspectiveCamera(45, 2, 0.1, 1000);
    camera.position.set(0, 0, 10);
    camera.lookAt(0,0,0);
    scene.add(camera);

    const ambientLight = new THREE.AmbientLight(0xffffff, 0.3);
    scene.add(ambientLight);

    const directionalLight = new THREE.DirectionalLight(0xffffff);
    camera.add(directionalLight);

    return {scene, camera};
}
```

次に、個別のシーンをオブジェクトの配列`sceneInitFunctionsByName`として定義します。
この中で、先程作成した`makeScene()`関数を呼び出してシーンを作成します。

```JavaScript
//各シーンを定義
const sceneInitFunctionsByName = {
    //boxを描画するシーン
    "box":() => {
        const {scene, camera} = makeScene();
        scene.background = new THREE.Color(0x333333);
        const geometry = new THREE.BoxGeometry(2,2,2);
        const material = new THREE.MeshPhongMaterial({
            color: 0xff0000,
        });
        const mesh = new THREE.Mesh(geometry, material);
        scene.add(mesh);
        return (time, rect) => {
            mesh.rotation.x = time;
            mesh.rotation.y = time;
            camera.aspect = rect.width / rect.height;
            camera.updateProjectionMatrix();
            renderer.render(scene, camera);
        };
    },
    //cornを描画するシーン
    "corn":() => {
        const {scene, camera} = makeScene();
        scene.background = new THREE.Color(0x111111);
        const geometry = new THREE.ConeGeometry(1, 3, 8);;
        const material = new THREE.MeshPhongMaterial({
            color: 0x00ff00,
        });
        const mesh = new THREE.Mesh(geometry, material);
        scene.add(mesh);
        return (time, rect) => {
            mesh.rotation.x = time;
            mesh.rotation.z = time;
            camera.aspect = rect.width / rect.height;
            camera.updateProjectionMatrix();
            renderer.render(scene, camera);
        }
    },
    //octahedronを描画するシーン
    "octahedron":() => {
        const {scene, camera} = makeScene();
        scene.background = new THREE.Color(0x222222);
        const geometry = new THREE.OctahedronGeometry(2, 0);;
        const material = new THREE.MeshPhongMaterial({
            color: 0x0000ff,
        });
        const mesh = new THREE.Mesh(geometry, material);
        scene.add(mesh);
        return (time, rect) => {
            mesh.rotation.x = time;
            mesh.rotation.z = time;
            camera.aspect = rect.width / rect.height;
            camera.updateProjectionMatrix();
            renderer.render(scene, camera);
        }
    }
}
```

次に、body要素から`data-scene属性`を持つすべての要素を探してきて、同じ値を持つシーンを割り当てます。

```JavaScript
//全てのシーンを管理する配列
const sceneElements = [];

//配列にシーンを追加する関数
function addScene(elem, fn) {
  const ctx = document.createElement("canvas").getContext("2d");
  elem.appendChild(ctx.canvas);
  sceneElements.push({elem, ctx, fn});
}

//HTMLから全てのdata-sceneを探してきて、要素の数だけシーンを描画
document.querySelectorAll("[data-scene]").forEach((elem) => {
    //data-scene属性の値から、シーンの名前を取得する（box, corn, octahedron）
    const sceneName = elem.dataset.scene;
    //data-scene属性の値と同じ名前を持つシーンをsceneInitFunctionに代入する
    const sceneInitFunction = sceneInitFunctionsByName[sceneName];
    //data-scene属性を持つ要素内に、同じ名前のシーンを作成する
    const sceneRenderFunction = sceneInitFunction(elem);
    addScene(elem, sceneRenderFunction);
});
```

最後に、ブラウザの表示領域内（ViewPort）に入っている`data-scene属性`を持つ要素のシーンだけを描画するように、`render()`関数を定義します。

```JavaScript
//毎フレーム実行されるレンダー関数
function render(time){
    time *= 0.001;
    for (const {elem, fn, ctx} of sceneElements) {
        //ブラウザで表示される領域（ViewPort）を取得
        let rect = elem.getBoundingClientRect();
        const {left, right, top, bottom, width, height} = rect;
        const rendererCanvas = renderer.domElement;

        // ViewPort内に収まっていない場合は、isOffscreenフラグを立てる
        const isOffscreen =
            bottom < 0 ||
            top > window.innerHeight ||
            right < 0 ||
            left > window.innerWidth;

        // isOfscreenフラグがない = ViewPort内に表示されていれば、シーンをレンダリングする
        if (!isOffscreen) {
            if (rendererCanvas.width < width || rendererCanvas.height < height) {
                renderer.setSize(width, height, false);
            }

            if (ctx.canvas.width !== width || ctx.canvas.height !== height) {
                ctx.canvas.width = width;
                ctx.canvas.height = height;
            }

            renderer.setScissor(0, 0, width, height);
            renderer.setViewport(0, 0, width, height);

            fn(time, rect);

            ctx.globalCompositeOperation = 'copy';
            ctx.drawImage(
                rendererCanvas,
                0, rendererCanvas.height - height, width, height,
                0, 0, width, height);
                }
            }
        requestAnimationFrame(render);
}
```
これで、ページの読み込み時に`render関数`を呼び出せば、JavaScriptは完成です。
```JavaScript
window.addEventListener("DOMContentLoaded", render);
```
最後に、作成したcanvas要素がフルスクリーンで表示されるように調整して終了です。

## CSSでスクロール時の挙動を調整
```scss
*{
    box-sizing: border-box;
    margin: 0;
    padding: 0;
    list-style: none;
}

canvas{
    width: 100%;
    height: 100%;
}
*[data-scene]{
    width: 100%;
    height: 100vh;
    position: relative;
    z-index: 1;

    .wrap{
        position: absolute;
        top: 0;
        right: 1%;
        left: 1%;
        max-width: 700px;
        height: 100%; //これ大事
        margin: 0 auto;
        padding: 2.0rem;
        overflow-y: scroll;
        &::-webkit-scrollbar{
            display: none;
        }
        color: #ccc;
        h3{
            margin-top: 60px;
        }
    }
}
```

これで、1つのbody内に複数のシーンを描画できました。
ここからパララックスや慣性スクロールなどのインタラクションをつけていくと、Three.jsを利用したリッチなページを作成できるのではないかと思います！

全体のコードおよびデモは[こちら](https://codepen.io/outsider-kithy/pen/rNQZEZY)