🎥

WebGL入門 ~ three.js④~

2022/06/30に公開

今回は、three.jsの機能を活用する回!!
インタラクションや3Dモデルの読み込みなどなど。

いちおう前回までの記事を貼っておきます。
WebGL入門 ~ three.js ①~
WebGL入門 ~ three.js ②~
WebGL入門 ~ three.js ③~


3Dシーンにマウスで干渉する

一般的3D用語では、
ワールド空間・ワールド座標系 = オブジェクトが置かれている三次元空間のこと
→ three.js では、Scene がワールド空間を表してるといえる

→ three.jsのObject3Dがもつ position rotation を操作する
→ オブジェクトが「ワールド空間上でうごく」

→ レンダリングされてスクリーンにうつされるのは、2次元・・・
オブジェクトをマウスカーソルでクリックしたい ときはどうしたりいのか?

→「ワールド座標系」と「スクリーン座標系」を揃える計算・処理をして、衝突判定を行う!!

⇒ three.jsでは Raycaster(レイキャスター) を使うことで処理できる
three.js - Raycaster

Raycaster

  • マウスカーソルの位置(2D)を元に、3Dのワールド空間に対して レイ(Ray) を飛ばして衝突判定をす

    「レイ = 光線」 の意味。レーザーのイメージ。

  • レイがオブジェクトと衝突した場合、衝突した交点やUVなどもとれる。
  • IntersectObjectsIntersectObject があるので、衝突判定したいものが単体の場合は IntersectObject でもOK (どちらも配列でかえってくる)
  • 複数重なったオブジェクトをクリックし他場合でも、一番手前のオブジェクトが衝突したと判定される(配列の0番目を参照した場合)

    Raycasterを使い、マウスでクリックしたところだけ緑になるサンプル
// Raycaster のインスタンスを生成する
this.raycaster = new THREE.Raycaster();

// マウスのクリックイベントの定義 
window.addEventListener('click', (mouseEvent) => {

	// 正規化する
  const x = mouseEvent.clientX / window.innerWidth * 2.0 - 1.0;
  const y = mouseEvent.clientY / window.innerHeight * 2.0 - 1.0;

	// スクリーン空間では下が+/ワールド空間では上が+ なのでY軸を上下を反転させている
  const v = new THREE.Vector2(x, -y);

	// レイキャスターに、マウス座標とカメラを指定する
  this.raycaster.setFromCamera(v, this.camera);

	// 対象のオブジェクトをレイキャストする
  const intersects = this.raycaster.intersectObjects(this.torusArray);

	// 対象以外のオブジェクトをリセットするため、全部リセット
  this.torusArray.forEach((mesh) => {
    mesh.material = this.material;
  });

	// 衝突判定0番目のオブジェクトに用意したマテリアルをあてる
	if (intersects.length > 0) {
    intersects[0].object.material = this.hitMaterial;
  }
}, false);

raycasterに入れるとこから細かくみていく

this.raycaster.setFromCamera(v, this.camera);

レイキャスターに正規化済みマウス座標 v とカメラを指定する

const intersects = this.raycaster.intersectObjects(this.torusArray);

対象のオブジェクトをレイキャストする (今回は複数のトーラス)

if (intersects.length > 0) {
  intersects[0].object.material = this.hitMaterial;
}

衝突判定0番目のオブジェクトに用意したマテリアルを当てる(今回は緑のマテリアルに変えているが、meshを消したり色々できる)
0番目 = 一番手前のオブジェクトが入っている

独自のジオメトリを定義する

ここまで、three.jsで用意されているジオメトリを使用してきました。
球体とかトーラスとか・・・これらは THREE.BufferGeometry クラスを継承・拡張して実装されています。

自作のジオメトリを作るには・・・?

まずは点の描画から


ボックスじゃなくて点だよ

three.jsのジオメトリの元になっている THREE.BufferGeometry クラスには、
元々どんな形も定義されていません。
ここに 頂点を追加していきます。

点を描画するときのマテリアルには THREE.PointsMaterial をつかう。
Meshではなく、Pointsの場合はマテリアルのプロパティもちょっと違う。

static get MATERIAL_PARAM() {
  return {
    color: 0xffffff,
    size: 0.25,
    sizeAttenuation: true
  };
}

size はワールド座標の0.25ではなくpxなので size: 10と指定しても画面内におさまってます。
sizeAttenuation というのは、遠近感を出すかどうか。
falseにすると、奥にあっても手前にあっても同じ大きさで描画される(ピュアWebGLではこれが普通)
遠近感が出せるのは、three.js特有のプロパティ

で、ジオメトリ側。

ざっくり

  1. 頂点座標を定義
  2. 頂点座標を直列に配列似入れておく。 Float32Arrayにする。
  3. BufferAttributeインスタンスの形にする。
  4. 名前ラベル(好きにつける)つけて、BufferGeometry にひもづける

点1つづつではなく、点の集合体(パーティクル)として扱います。

Points(パーティクル)の定義するのに、まず座標情報などを定義。

this.geometry = new THREE.BufferGeometry(); // 特定の形状を持たないジオメトリ

const COUNT = 10;    // パーティクルの行と列のカウント数
const WIDTH = 10.0;  // どの程度の範囲に配置するかの幅
const vertices = []; // まず頂点情報を格納する単なる配列(Array)

for (let i = 0; i <= COUNT; ++i) {
  // カウンタ変数 i から X 座標を算出
  const x = (i / COUNT - 0.5) * WIDTH;
  for(let j = 0; j <= COUNT; ++j){
    // カウンタ変数 j から Y 座標を算出
    const y = (j / COUNT - 0.5) * WIDTH;
    // 配列に頂点を加える
    vertices.push(x, y, 0.0);
  }
}

ジオメトリの準備と、パーティクルの頂点情報を配列に格納する。

three.js では、ジオメトリに頂点の情報を設定する(上書きや追加する) 場合は、 BufferAttributeクラスを利用します。
定義した頂点の情報を加工する

const stride = 3;

// BufferAttribute の生成
const attribute = new THREE.BufferAttribute(new Float32Array(vertices), stride);

// position という名前に対して BufferAttribute を割り当てる
this.geometry.setAttribute('position', attribute);

BufferAttribute クラスは、データの入力として TypedArray を使うので、
適切な TypedArray を利用すること(今回だとFloat32Array)

stride は、頂点情報がいくつの要素を持つか。XYZなので3を指定すること⚠️
※ストライドは three.js のドキュメントなどでは itemSize と記載されている

ここまでで、ジオメトリに「座標」が定義されました!👏
(まだ、座標の情報状態)

パーティクルを格納したジオメトリとマテリアルからポイントオブジェクトを生成

this.points = new THREE.Points(this.geometry, this.material);

シーンにパーティクルを追加

this.scene.add(this.points);

ポイントスプライト

ポイントでは、点しか描画できない?(四角いし・・・)
→ テクスチャで色々見せ方を変えることができるよ★☆

星のテクスチャはったイメージ

ポイントスプライト = 点(ポイント)として描かれる頂点を、スプライト(二次元のビットマップが付与されたオブジェクト)として扱う

three.js では、単純に「点に対して設定したマテリアルにテクスチャを割り当てる」だけでこれを実現することがでる!お手軽!

this.material = new THREE.PointsMaterial(App3.MATERIAL_PARAM);
this.material.map = this.texture;

貼り付けても、点は正方形のままなので、どこからみても同じテクスチャ(3D空間に合わせて変形したりはしない。) 正対する姿勢をとる(ビルボードと呼ぶこともある)

ちなみに、正方形ではない画像を貼ると歪む

・・・ただ、貼っただけだと、透過したい部分(星じゃない正方形の部分)が透過できていない。
→ 透過処理をすると、第2回の透過で説明したように、前後関係がバグる。
→ じゃあ奥の点から描画すればいいのだけど、
1つ1つの点がオブジェクトではなく、点の集合体のオブジェクトなのでそれはできない。
(1つ1つをオブジェクトにすることは可能だが効率が悪いのでオススメしない)

ポイントスプライト + 透過処理

マテリアルの設定を調整して、ポイントスプライトの時に透過画像を使えるようにする

  • transparent: true にする
  • opacity: 0.8 とかにして、少しすけさせる
  • 色のブレンド方法を AdditiveBlending にする
  • 深度テストを切る → すべてのピクセルを強制的に描画する
static get MATERIAL_PARAM() {
  return {
    color: 0xffcc00,                  // 頂点の色
    size: 0.5,                        // 頂点の基本となるサイズ
    sizeAttenuation: true,            // 遠近感を出すかどうかの真偽値
    opacity: 0.8,                     // 不透明度
    transparent: true,                // 透明度を有効化するかどうか
    blending: THREE.AdditiveBlending, // 加算合成モードで色を混ぜる
    depthWrite: false                 // 深度値を書き込むかどうか
  };
}

この設定にすると、

  • 前後関係が比較的気にならない
  • 透過できるので正方形が見えない
  • 星が透過・加算合成モードになる

という状態。

星自体は透過したくない場合は、ブレンドモード設定せずに、opacity: 1.0 にすることで可能だが、透過と深度テストの相性がよくないので、前後関係がミスる部分が出てくる。

通常の透過(NormalBlending)は色を混ぜ合わせる計算方法だが、加算合成の状態は色をたす(重なると明るくなる)ので、前後関係が気にならない。

3Dモデルを読み込む

複雑な形を使いたい時、
3Dモデル自分で作るのも、ビルトインのジオメトリを組み合わせるのも、大変だよね。

3Dモデルデータの配布サイトがいろいろあります

3Dモデルデータの配布・販売サイト
ライセンスには注意。

などなど。


かわいい🦊

さて、

3Dは複雑なのでモデルの形式(フォーマット)がいろいろある。

そこで glTF という統一された中間フォーマットが策定されました。
https://github.com/KhronosGroup/glTF

three.jsでも GLTFLoader が用意されています。

import {GLTFLoader} from '../lib/GLTFLoader.js';

テクスチャの時と同じ感じでglbファイルをロードする

load() {
  return new Promise((resolve) => {
    const gltfPath = './Duck.glb'; // 読み込むファイルのパス
    const loader = new GLTFLoader();
    loader.load(gltfPath, (gltf) => {
      // あとで使えるようにプロパティに代入しておく
      this.gltf = gltf;
      resolve();
    });
  });
}

シーンに追加する

this.scene.add(this.gltf.scene);
  • glTF にはシーン全体の情報を含めることができる仕様になっている。
  • three.js もそれにならっているので、読み込み後に返されるオブジェクトは scene というプロパティを持っている。
  • scene = Object3Dで、子要素にメッシュなどのデータをもつ。(glTFのsceneは色々まとまった塊のイメージで、それごとaddする)

アニメーションつきglTF

アニメーションを含むglTFもある。

複数のモーションを含んでいる時、
ウェイトという概念を用いてアニメーションを切り替えたり合成したりする。

  • アニメーションを含むglTFを用意する
  • glTFからアニメーションを取り出す
  • 用意したミキサーに通して、アクション化する
  • アクションの割合を決めて動かす

さっきのloadに追加する

load() {
  return new Promise((resolve) => {
    const gltfPath = './Fox.glb';
    const loader = new GLTFLoader();
    loader.load(gltfPath, (gltf) => {
      this.gltf = gltf;

      // ミキサーを生成する(scene プロパティを渡す点に注意)
      this.mixer = new THREE.AnimationMixer(this.gltf.scene);

      // アニメーション情報を取り出す
      const animations = this.gltf.animations;

      // 取り出したアニメーション情報を順番にミキサーに通してアクション化する
      this.actions = [];
      for(let i = 0; i < animations.length; ++i){
        // アクションを生成
        this.actions.push(this.mixer.clipAction(animations[i]));

        // ループ方式を設定する
        this.actions[i].setLoop(THREE.LoopRepeat);

        // 再生状態にしつつ、ウェイトをいったん 0.0 にする
        this.actions[i].play();
        this.actions[i].weight = 0.0;
      }

      // 最初のアクションのウェイトだけ 1.0 にして目に見えるようにしておく
      this.actions[0].weight = 1.0;

      resolve();
    });
  });
}
  • play は再生というより、再生できる状態にしておく感じ

  • weightの割合を変えるとアニメーションをブレンドできる

    this.actions[0].weight = 0.5;
    this.actions[1].weight = 0.5;
    

    などとすると、0と1の動きが混ざった動きをする (0.0~1.0の値をとる)

アニメーションの速度はrender(毎フレーム)で書かれている

const delta = this.clock.getDelta(); // 経過時間
this.mixer.update(delta);

この場合、deltaを大きくすると早くなり、小さくすると遅くなる。
(this.clockはClock オブジェクトを設定しておく)

glTFの中身が見たい時はBabylon.jsのビューアがべんり
https://sandbox.babylonjs.com/

オフスクリーンレンダリング

スクリーンに映らないレンダリング
(ポストプロセスもオフスクリーンレンダリングを活用した技術)

つかいどころ

  • 各種ポストエフェクト
  • 鏡やツルツルした面への映り込みの表現
  • ワイプのような表現
  • 影を落とす処理
  • 水や光学迷彩などの透明な質感の表現

実際にCGで場面を使っていくとき、使わない場合のが少ないらしい。

たとえばこういうかんじ。


板ポリにテクスチャとして貼られる3Dアヒルさん🐥

three.jsでは THREE.WebGLRenderTarget というオブジェクトを使う。
今までにもずっと利用してきた THREE.WebGLRenderer というオブジェ
クトに対して THREE.WebGLRenderTarget を割り当てることで、「一旦レンダリング」することができる

RenderTargetを使わない場合(今まで)
→ そのままプロジェクターでcanvasに投影しているイメージ

  • シーンもカメラも1つ
  • アスペクト比はWindowから

RenderTargetを使う場合
→ オフスクリーンでレンダリングされ、画面にはでなくなる。
→ 「一旦レンダリング」することでできることが広がる感じ。
 オフスクリーンレンダリングしたものを板ポリにはったり、エフェクトかけたり・・・
  (テクスチャなどにして最終レンダリングで描画する)

  • オフスクリーン用のカメラとシーン、最終レンダリング用のシーンとカメラが必要
  • 最終的に投影する板ポリのサイズに変更する(正方形とか)
**// シーン**
this.scene = new THREE.Scene();

// オフスクリーン用のシーン
// 以下、各種オブジェクトやライトはオフスクリーン用のシーンに add しておく
this.offscreenScene = new THREE.Scene();

オフスクリーンの方のシーンは、画面のサイズではなく、レンダーする対象のサイズにする。
今回だと正方形の板ポリのサイズを指定する

// レンダーターゲットをアスペクト比 1.0 の正方形で生成する
this.renderTarget = new THREE.WebGLRenderTarget(App3.RENDER_TARGET_SIZE, App3.RENDER_TARGET_SIZE);

オフスクリーンのカメラの方でも、サイズなどは板ポリに合わせる

// オフスクリーン用のカメラは、この時点でのカメラの状態を(使いまわして手間軽減のため)クローンしておく
this.offscreenCamera = this.camera.clone();

// レンダーターゲットは正方形なので、アスペクト比は 1.0 に設定を上書きしておく
this.offscreenCamera.aspect = 1.0;
this.offscreenCamera.updateProjectionMatrix();

// レンダリング結果を可視化するのに、板ポリゴンを使う
const planeGeometry = new THREE.PlaneGeometry(5.0, 5.0);
const planeMaterial = new THREE.MeshBasicMaterial({color: 0xffffff});
this.plane = new THREE.Mesh(planeGeometry, planeMaterial);

// 板ポリゴンのマテリアルには、レンダーターゲットに描き込まれた結果を投影する
// マテリアルの map プロパティにレンダーターゲットのテクスチャを割り当てておく
planeMaterial.map = this.renderTarget.texture;

// 板ポリゴンをシーンに追加
this.scene.add(this.plane);

オフスクリーンレンダリングしたシーンをテクスチャとして板ポリにはる
言い換えると、板ポリがオフスクリーンレンダリング用のcavasになっているようなイメージ

planeMaterial.map = this.renderTarget.texture;

ここはいつもの。最終レンダリングのためにシーンにオブジェクトを追加している

this.scene.add(this.plane);

ここまでは、init関数の中で設定。

render関数の中でレンダリングする。

オフスクリーンレンダリング

// まず最初に、オフスクリーンレンダリングを行う
this.renderer.setRenderTarget(this.renderTarget);

// オフスクリーンレンダリングは常に固定サイズ
this.renderer.setSize(App3.RENDER_TARGET_SIZE, App3.RENDER_TARGET_SIZE);

// わかりやすくするために、背景を黒にしておく
this.renderer.setClearColor(this.blackColor, 1.0);

// オフスクリーン用のシーン(Duck が含まれるほう)を描画する
this.renderer.render(this.offscreenScene, this.offscreenCamera);

レンダリング

// 次に最終的な画面の出力用のシーンをレンダリングするため null を指定しもとに戻す
this.renderer.setRenderTarget(null);

// 最終的な出力はウィンドウサイズ
this.renderer.setSize(window.innerWidth, window.innerHeight);

// わかりやすくするために、背景を白にしておく
this.renderer.setClearColor(this.whiteColor, 1.0);

// 板ポリゴンが1枚置かれているだけのシーンを描画する
this.renderer.render(this.scene, this.camera);

記述としては同時になりますが、それぞれ工程ごと分けて考えると、新しいことはそんなにしてないので、図解などして書いていくこと🐥

ドロップシャドウ(影)

three.jsでは

  • 影を有効化する設定をする
  • 「ライトの設定」と「影を落とす設定」と「影を受ける設定」をそれぞれのオブジェクトにする

(ピュアなWegGLではめちゃくちゃ大変らしい…)

まず定数

ライトの設定が変わる

static get DIRECTIONAL_LIGHT_PARAM() {
  return {
    color: 0xffffff,
    intensity: 1.0,
    x: 50.0,
    y: 50.0,
    z: 50.0,
  };
}

ディレクショナルライトは方向だけ、という話だったが、
影を使う場合は、ディレクショナルライトであっても位置が重要になる(3DCGの場合)

影の設定

static get SHADOW_PARAM() {
  return {
    spaceSize: 100.0, // 影を生成するためのカメラの空間の広さ
    mapSize: 512,     // 影を生成するためのバッファのサイズ
  }
}

mapSizeのバッファのサイズはpx (上記設定だと512px四方)
128とかにすると、影が荒くなる

レンダラーに対して影を有効にする設定 (init)

this.renderer.shadowMap.enabled = true;
this.renderer.shadowMap.type = THREE.PCFShadowMap;

enabled = true にしてから、typeを選ぶ。
影の境界線をぼやかすタイプや、有無でのみ判断するタイプなど

ライトを castShadow = true にして影を落とすように設定

// ディレクショナルライトが影を落とすように設定する
this.directionalLight.castShadow = true;

// 影用のカメラ(平行投影のカメラ)の範囲を広げる
this.directionalLight.shadow.camera.top    =  App3.SHADOW_PARAM.spaceSize;
this.directionalLight.shadow.camera.bottom = -App3.SHADOW_PARAM.spaceSize;
this.directionalLight.shadow.camera.left   = -App3.SHADOW_PARAM.spaceSize;
this.directionalLight.shadow.camera.right  =  App3.SHADOW_PARAM.spaceSize;

// 影用のバッファのサイズは変更することもできる
this.directionalLight.shadow.mapSize.width  = App3.SHADOW_PARAM.mapSize;
this.directionalLight.shadow.mapSize.height = App3.SHADOW_PARAM.mapSize;

ライトはカメラを内包しており、その視点での深度テストをする(オフスクリーンレンダリング)
レンダリングするカメラと比較して、影を落とす部分を判定する

ライトのヘルパーを出すとわかりやすい(さっきの画像だと、立方体のとかはHelper)

const cameraHelper = new THREE.CameraHelper(this.directionalLight.shadow.camera);
this.scene.add(cameraHelper);

「影を落とす側」の設定🦊
狐のcastShadow = true にする

// glTF の階層構造をたどり、Mesh が出てきたら影を落とす(cast)設定を行う
this.gltf.scene.traverse((object) => {
  if (object.isMesh === true || object.isSkinnedMesh === true) {
    object.castShadow = true;
  }
});

Object3Dtraverse というプロパティがあり、階層をたどって判定してくれる

「影をうける側」の設定(床)
receiveShadow = true にする

// 床面をプレーンで生成する
const planeGeometry = new THREE.PlaneGeometry(200.0, 200.0);
const planeMaterial = new THREE.MeshPhongMaterial();
this.plane = new THREE.Mesh(planeGeometry, planeMaterial);

// プレーンは XY 平面に水平な状態なので、後ろに 90 度分倒す
this.plane.rotation.x = -Math.PI * 0.5;

// 床面は、影を受ける(receive)するよう設定する
this.plane.receiveShadow = true;

あとがき

Raycasterはお仕事でもかなり使えそうだな〜と思ったので、機会を狙って使います・・・😎

あと、3Dモデルを出すだけでテンションが上がるので、いろいろダウンロードして試してみます。

あとあと、この講義を受けた後、サイトをいろいろ見ては、
「このサイトはオフスクリーンレンダリングで動画を流してるっぽい」とか
「Raycasterだ・・・」とか、いろいろ気付けるようになって、ちょっとできる気分になってます。
(手を動かせ・・・)

次回からはピュアなWebGLやっていくそうです・・・どきどき。

Discussion