WebGLを使ったグリグリ動くWebコンテンツをチームで作ってみた【3Dエンジニア編】



# 初めに
WebGLで3Dを活用したWebサイト、「 [3DWebSite](https://pcpo.sabo.jp/3dwebsite) 」をPlayCanvasを利用して開発しました。
![Imgur](https://imgur.com/bj1IEIF.png)

PlayCanvas : https://playcanvas.jp
3DWebsite : https://playcanvas-demo.jp/examples/web/3dwebsite/

ここでは、3DWebSiteを開発した際に得られた、__ゲームエンジンを利用してWebサイトを作る__知見を残すためそれぞれの担当メンバーが解説記事を執筆しています。

**解説記事一覧**
[【3Dエンジニア編】](https://qiita.com/utautattaro/items/23f6bc6d3f50b21bf52f)　　　←　いまここ
[【3Dモデラー編】](https://qiita.com/shujimuna1102/items/89947ffd6a4a79ff1db5)
[【フロントエンドエンジニア編】](https://qiita.com/yushimatenjin/items/de1aa22ab05fa45d79cc)
[【デザインエンジニア編】](https://qiita.com/sutobu000/items/827df2d1cd77b5c4b025)

3DWebSite解説【3Dエンジニア編】
---

[3DWebsite](https://pcpo.sabo.jp/3dwebsite)の**【3Dエンジニア】**を担当した うたろ🐘([@utautattaro](https://twitter.com/utautattaro)) です。ここではCanvas要素、3D関連のオブジェクト処理の実装について具体的に解説しています。





3D関連の処理についてはすべてPlayCanvas Editor上で実装を行い、スクリプティングもすべてPlayCanvas Editorで行いました。

# カメラ制御
カメラの制御については、大きく3つのポイントがあります。

1つめは、**パスポイントの指定**についてです。3DWebsiteでは複数のパスポイントがあらかじめ設定されており、要素を変更していくたびにカメラの視点が変化する設計となっています。

2つめは、**カメララーピング**についてです。1つめに指定したパスポイントをユーザー操作によってカメラが滑らかに移動するような処理を実装しました。

3つめは、**フレキシブルなパンの実装**です。ユーザー入力によってカメラがパンする際、視点に対して正しく平行移動するような処理について記載します。

## パスポイントの指定
パスポイントはdisableなCameraコンポーネントを保持した空のエンティティとしてワールド上に配置されており、デザイナーが自由にカメラを調整できるように構築してあります。
![Imgur](https://imgur.com/oGfEFFs.gif)

※撮影用にCameraコンポーネントがenableになっています


## カメララーピング

![1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/148084/6b48346e-081e-d537-fa2e-87e36b5b8bac.gif)




ページを切り替えるとカメラが指定位置までスムーズに動くような処理を実装しています。

PlayCanvasにはあらかじめ指定されたパスをカメラが移動していくようなサンプルが存在します。
https://developer.playcanvas.com/ja/tutorials/camera-following-a-path/


ですがこちらの方法だと、すべてのパスポイントを補完したベジエ曲線上に単一のルートでしか軌道を描けません。

そのため今回のようなポイントを指定して変化していくタイプの実装には向いていないため、今回カメラのラーピングには[tween.js](https://github.com/playcanvas/playcanvas-tween)を利用しました。

![pathfollowandtween.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/148084/e0f03aec-61be-39ad-8bfc-9844213d5c77.png)


ラーピングの処理は以下のように記載されています。

```js:camera-path-usingTween.js
//カメラにアタッチされているスクリプト
CameraPathUsingTween.prototype.initialize = function() {
  //カメラエンティティそのものにアタッチ
  this.entity.moving = function(pathpoint,duration,easing){
      //座標をラープ
      this.tween(this.getLocalPosition()).to(pathpoint.localPosition, duration,easing)
      .loop(false)
      .yoyo(false)
      .on('update', function () {
          isTweenUpdate = true;　//ラーピング開始
      })
      .on('complete',function(){
          isTweenUpdate = false; //ラーピング終了
      })
      .start();

      //角度をラープ
      this.tween(this.getLocalEulerAngles()).rotate(pathpoint.eularangle, duration,easing)
      .loop(false)
      .yoyo(false)
      .start();
  };
}
```

この上に実際に叩くメソッドを用意しグローバルから叩ける状態にしました

```js:cameraControl.js
CameraControl.prototype.initialize = function(){
  let self = this;
  //カメラの位置を指定する関数
  pcpo.member.tsuda.setCameraSequence = function(sequence,duration = 5.0,easing = pc.QuinticOut){
      if (sequence >= pcpo.member.tsuda.pathRoot.length || sequence < 0) {
          throw new Error("cameraSequenceは0以上pathRoot.length未満の整数値で指定");
      } else {
          self.entity.moving(pcpo.member.tsuda.pathRoot[sequence],duration,easing);
      }
  };
};
```

camera-pathエンティティ内に存在するカメラパスをいったんすべて走査し、初期化時にカメラパスの座標・回転データを格納しておきます。その後ユーザー入力に応じて現在の座標情報から対象のカメラパスへラーピングを書けるような実装をしています。


durationやeasingを値指定引数に設定してあるため、デフォルト以外のラーピングを利用することも可能です。
![setCameraSequence.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/148084/833a7e06-a1d6-ca34-4a12-82d6297f2917.gif)



Camera-following-pathでの実装と比較してみましょう
![camera-path.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/148084/1dbfc06f-80ac-5afd-d32c-28fac72e78df.gif)

こちらは同一ルートを滑らかに移動させ、ステージ全体を見せたいような場合に使えそうな実装です。

## フレキシブルなパンの実装
3DWebsiteでは、カメラがカメラパスに到着した後、DOMの外をドラッグすることで自由に平行移動(パン)することができます。

![pan.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/148084/df3ae8a4-3c75-95db-0408-b97c781d69bf.gif)


すべてのカメラパスはそれぞれ別の方向を向いているため、単純にカメラ座標を変更するだけでは実装できません。そこで今回は、パンの実装にPlayCanvas公式が用意している*model viewer starter Kit*内にあるorbit-camera.jsを利用しました。

orbit-camera.js内の_updatePosition()を以下のように変更ました。

```js:orbit-camera.js
OrbitCamera.prototype._updatePosition = function () {
    //移動方向と移動量を算出
    var position = new pc.Vec3();
    position.copy(this.entity.forward);
    position.add(this.pivotPoint);
    position.scale(this.moveScale);
    
    if(!oldPosition.equals(position) && !pcpo.member.tsuda.isTweenUpdate){
      //座標に更新がありラーピング中でなければ移動する
      this.entity.translate(position.clone().sub(oldPosition));
      pcpo.member.kido.translate = false;
      oldPosition = position.clone();
    }
};
```

orbit-cameraと違い、今回はtargetとなるEntityが固定されておらず、パスポイントによって基準点が異なるため、orbit-cameraで独自定義されていたAABBの記述を削除し、
カーソル/タッチの移動量に合わせpanを行う実装としました。

デフォルトではOrbitCamera._updatePosition()がOrbitcamera内のupdate()で毎フレーム呼び出されており、カメララーピングと競合してしまうため、update()内ではなく
タッチ/マウスイベントハンドラ内で呼び出すような挙動に変更しています。

# テクスチャアニメーション
3DWebsite内では多くのエンティティが配置され、動きのあるものはそれぞれテクスチャアニメーションとボーンアニメーションで実装されています。また、スクリプトからモデルアニメーションを制御し、テクスチャアニメーションを行う、といったこと等もしています。

##  -  UVスクロール
ステージ内全体を這うケーブルの表現はUVスクロールで表現しています。

![uvscroll.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/148084/74a7821c-069e-6c6b-a356-db37cfe03c76.gif)

UVスクロールを使うことで、テクスチャやエンティティの修正/差し替えを行うことなく、テクスチャのシームレスな移動表現を実装することが可能です。常に動き続ける水面のような表現に向いています。

UVスクロールを実装する場合は、まずUVスクロールを行いたい対象マテリアルを切り分ける必要があります。

今回はフィールドのモデルが保有している5つのマテリアルのうち、ケーブルのマテリアルのみを切り分け、シンプルにy軸のみをスクロールすることでステージ全体のケーブルが変更されるような設計にしました。

![uvscroll_hand.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/148084/7067ec3f-2363-aeac-afae-ce9bafe40fee.gif)


対象マテリアルが特定できたらあとはスクリプトでUVスクロールを実装します。コードは以下のようになります。

```js:uvscroll.js
//ケーブルのテクスチャをスクロールするスクリプト

var Uvscroll = pc.createScript('uvscroll');
Uvscroll.attributes.add('amount', {type: 'number', default: 400});
Uvscroll.attributes.add("scrollTargetMaterial",{type:"asset"}); //スクロール対象のマテリアルをセット

// initialize code called once per entity
Uvscroll.prototype.initialize = function() {
    this.mat = this.scrollTargetMaterial.resource;
    this.splitlate = 100; // 分解能
};

// update code called every frame
Uvscroll.prototype.update = function(dt) {
    
    var t = (new Date().getTime() / this.amount) % 1;
    var intT = parseInt(t*this.splitlate,10);
    
    //オフセットを変更
    this.mat._diffuseMapOffset = new pc.Vec2(0,intT/this.splitlate);
    //マテリアルをアップデート
    this.mat.update();
};
```

##  -  テクスチャ切り替え
サーバーの点滅や、ハンコが押される処理などはシンプルなテクスチャの入れ替えで実装しています。
![server.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/148084/7662539f-03ea-6214-9a00-827be8bd11dc.gif)

![stamp.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/148084/e0755d77-2525-03f6-620c-518fb469f682.gif)


テクスチャの入れ替えには、まず汎用的なスクリプト"TextureChanger.js"を作成しました。

サーバーのTextureChanger.jsのパラメーター
![tc_server.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/148084/484920a1-92c6-f4e3-1046-8f43fc5ddd00.png)


スタンプボーイのパラメーター
![tc_boy.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/148084/386b5ef2-1650-ab8d-6441-520122596639.png)


スクリプトは以下のようになっています。

```js:TextureChanger.js
//このスクリプトがアタッチされたentityのテクスチャを入れるスクリプト
var TextureChanger = pc.createScript('textureChanger');
//テクスチャを入れ替える対象のメッシュインスタンスを指定します。
TextureChanger.attributes.add("meshInstanceID",{type:"number",default:0});
//マテリアルのどのテクスチャを入れ替えるか指定します
TextureChanger.attributes.add('ShadingNetwork', {
    type: 'number',
    enum: [
        { 'deffuse': 1 },
        { 'emissive': 2 },
        { 'opacity': 3 }
    ],
    default:1
});
//変更予定のテクスチャを指定します。配列で追加することも可能です。
TextureChanger.attributes.add("changeTextrue",{type:"asset",assetType: 'texture',array:true});

// initialize code called once per entity
TextureChanger.prototype.initialize = function() {
    this.defaultTexture = new pc.Texture();
    switch(this.ShadingNetwork){
        case 1 : this.defaultTexture = this.entity.model.meshInstances[this.meshInstanceID].material._diffuseMap; break;
        case 2 : this.defaultTexture = this.entity.model.meshInstances[this.meshInstanceID].material._emissiveMap; break;
        case 3 : this.defaultTexture = this.entity.model.meshInstances[this.meshInstanceID].material._opacityMap; break;
        default : break;
    }
    //デフォルトテクスチャを0番、以降チェンジ対象テクスチャを続けて格納した配列を作成
    this.textures = [this.defaultTexture,...this.changeTextrue.map((texture)=>texture.resource)];
};

//IDを指定してテクスチャをチェンジするメソッド
TextureChanger.prototype.change = function(changeID){
    this.switchTexture(this.changeTextrue[changeID].resource);
};

//デフォルトのテクスチャに戻すメソッド
TextureChanger.prototype.backToOriginal = function(){
    this.switchTexture(this.textures[0]);
};

//現状格納されているテクスチャからランダムで指定するメソッド
TextureChanger.prototype.blink = function(){
    this.switchTexture(this.textures[parseInt(Math.random()*this.textures.length,10)]);
};

//テクスチャを変更している内部メソッド
TextureChanger.prototype.switchTexture = function(texture){
    switch(this.ShadingNetwork){
        case 1 : this.entity.model.meshInstances[this.meshInstanceID].material._diffuseMap = texture; break;
        case 2 : this.entity.model.meshInstances[this.meshInstanceID].material._emissiveMap = texture; break;
        case 3 : this.entity.model.meshInstances[this.meshInstanceID].material._opacityMap = texture; break;
        default : break;
    }
    this.entity.model.meshInstances[this.meshInstanceID].material.update();
};
```

サーバーのようにただ点滅する場合は、textureChanger.blink()を呼び出すだけで格納されたテクスチャが点滅します。

```js:serverblink.js
//サーバーをびかびかさせる
var Serverblink = pc.createScript('serverblink');

// initialize code called once per entity
Serverblink.prototype.initialize = function() {
    this.count = 0;
};

// update code called every frame
Serverblink.prototype.update = function(dt) {
    this.count++;
    if(this.count % 3 == 0){
        this.entity.script.textureChanger.blink();
    }
};
```

スタンプボーイのように確率でテクスチャを切り分ける場合はtextrueChanger.change()を利用して以下のように記述します

```js
//101分の1の確率で、2のテクスチャ、残り半分ずつ0か1のテクスチャに変更
var n = parseInt(Math.random()*101,10);
this.paperEntity.script.textureChanger.change((n == 100)?2:(n>50)?0:1);
```

# ボーンアニメーションとの連動

移動しているペーパーはPlayCanvasのtranslateではなく、ボーンアニメーションで実装されています。
![paper_boneanim.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/148084/b156bbe3-1057-9d5e-8050-b1f009ec59bb.gif)


ボーンアニメーションでのnode座標を取得し、特定位置に来た時にアニメーション発火、パーティクル発火、テクスチャアニメーションをするような処理を実装しています。

ボーンアニメーションで変化するエンティティ位置は、対象nodeに対してgetPosition()メソッドを利用することで取得することができます。

```js
var nodePos = this.paperEntity.model.model.meshInstances["0"].node.getPosition().clone();
```

また3DWebSiteでは座標系を統一するためほぼすべてのエンティティは原点(0,0,0)を基準として配置されているため、ペーパーのnode座標に合わせ、正しくPlayCanvasのワールド座標に合うようなオフセット位置を算出し、offsetエンティティを配置します。

![offset.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/148084/217a2ab6-8cce-2de8-f5eb-14e8862461a4.png)


オフセット位置と合わせることで、ボーンアニメーションにより移動しているエンティティの見え方の位置を取得することができます。

```js
var worldPos = this.offsetEntity.getLocalPosition().clone().add(nodePos);
```

ワールド座標が取得できたら、アニメーション発火タイミングとなるターゲットポジションを持ったエンティティを作成します。
![animationTarget.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/148084/9bab1de2-d25e-e154-29b9-d8343dacf63b.png)


ベクトルの距離から、アニメーション発火タイミングを計測し、トリガーとします。

```js:stampAnimationPlayStop.js
StampAnimationPlayStop.prototype.update = function(dt) {
    this.distance = new pc.Vec3().sub2(worldPos,this.targetPos).length();

    if(this.distance < 1){ //距離が閾値を割ったら
        this.Animplay(); //アニメーションを再生
    }

    if(this.paperEntity.animation.currentTime === 0){ // ペーパー初期化
        this.paperEntity.script.textureChanger.backToOriginal(); //テクスチャ初期化
        this.stampParticle.particlesystem.reset(); //パーティクル初期化
        this.stampflag = false; //フラグ初期化
    }
    
    if(this.animEntity.animation.currAnim === "stamp_boy.json"){
        //スタンプを押すアニメーション再生中は、進行時間でステータスを管理する
        this.status = parseInt(this.animEntity.animation.currentTime/this.animEntity.animation.duration * 100,10);
        

        if(this.status > 35  && !this.stampflag){
            //ハンコを押したタイミングでテクスチャを差し替え
            var n = parseInt(Math.random()*101,10);
            this.paperEntity.script.textureChanger.change((n == 100)?2:(n>50)?0:1);
            //パーティクル再生
            this.stampParticle.particlesystem.play();
            //スタンプを押したフラグを立てる
            this.stampflag = true;
        }
        
        if(!this.animEntity.animation.playing){//スタンプ押下アニメーション終了後に通る
            //アニメーションをアイドル状態に戻す
            this.animEntity.animation.play("bench_mark_boy3_idle.json",0.0);
            this.animEntity.animation.loop = true;
        }
    }
};

StampAnimationPlayStop.prototype.Animplay = function(){
    this.animEntity.animation.play("stamp_boy.json",0.0);
    this.animEntity.animation.loop = false;
};
```

以上、3DWebSite解説：3Dエンジニア編でした。