ITエンジニア勉強ブログ

自分が学んだことを共有していきます。

Three.jsのBufferGeometryの使い方

この記事ではThree.jsのBufferGeometryの基本的な使い方を紹介します。


See the Pen
Three.js BufferGeometry Sample
by Imai (@imai1)
on CodePen.

バージョン非互換が激しく過去の解説記事が当てにならなかったり、公式ドキュメントがあまり充実していなかったりで、結構苦労しました。この記事のコードはバージョンr142で動作確認しました。

BufferGeometryの概要

BufferGeometryクラスは、メッシュの頂点、面、法線、UV、マテリアル設定、etc... を管理する非常に重要なクラスです。BoxGeometryなどのプリミティブも、内部的にはBufferGeometryのサブクラスです。

Three.jsのユーザは、ジオメトリをコードで動的に生成したい場合などにBufferGeometryを直接作成します。

頂点および面情報の設定は頂点インデクスを用いない場合と用いる場合の二つがあります。どちらの方法かによって各種設定のやり方が異なるため、それぞれ分けて説明していきます。

頂点インデクスを用いない場合

例として、立方体を自前で作るケースを紹介します。まずコード全体を掲載し、後から各部分について説明します。冒頭のCodePenでは赤と青の立方体の生成部分に対応します。

// Cube
const originalVertices = [
  [-10, -10, -10],
  [+10, -10, -10],
  [+10, +10, -10],
  [-10, +10, -10],
  [-10, -10, +10],
  [+10, -10, +10],
  [+10, +10, +10],
  [-10, +10, +10],
];
const faces = [
  [1, 0, 2, 3],
  [0, 1, 5, 4],
  [1, 2, 6, 5],
  [2, 3, 7, 6],
  [3, 0, 4, 7],
  [4, 5, 6, 7],
]

function buildNonIndexedBufferCubeMesh() {
  const geometry = new THREE.BufferGeometry();

  const vertexBuffer = [];
  for (const face of faces) {
    const v0 = originalVertices[face[0]];
    const v1 = originalVertices[face[1]];
    const v2 = originalVertices[face[2]];
    const v3 = originalVertices[face[3]];
    vertexBuffer.push(...v0.concat(v1, v2)); // a triangle
    vertexBuffer.push(...v2.concat(v3, v0)); // a triangle
  }

  const vertices = new Float32Array(vertexBuffer);
  geometry.setAttribute("position", new THREE.BufferAttribute(vertices, 3));

  const numVertices = vertexBuffer.length / 3;
  geometry.addGroup(0, numVertices / 2, 0);
  geometry.addGroup(numVertices / 2, numVertices / 2, 1);

  geometry.computeVertexNormals();

  const material0 = new THREE.MeshLambertMaterial({color: 0xFF0000});
  const material1 = new THREE.MeshLambertMaterial({color: 0x0000FF});
  const materials = [material0, material1];
  const mesh = new THREE.Mesh(geometry, materials);
  return mesh;
}

頂点および面の設定

まず、頂点および面の設定はコード内の以下の箇所です。

  const vertices = new Float32Array(vertexBuffer);
  geometry.setAttribute("position", new THREE.BufferAttribute(vertices, 3));

BufferAttributeの第一引数は、面、頂点、頂点成分の順に並べたFloat32Array型の一次元配列を指定します。具体的には以下のような並びの配列です。

  • 面1の頂点1の成分1
  • 面1の頂点1の成分2
  • 面1の頂点1の成分3
  • 面1の頂点2の成分1
  • 面1の頂点2の成分2
  • 面1の頂点2の成分3
  • 面1の頂点3の成分1
  • 面1の頂点3の成分2
  • 面1の頂点3の成分3
  • 面2の頂点1の成分1

なお、BufferAttributeの第二引数は公式によると成分のサイズの3とのことです。面内の頂点数が3で固定なのか可変なのかは確たる証拠が見つけられていませんが、特に設定方法が見当たりませんし、WebGLだと3で固定ですし、おそらく前者なのではないかと……。なお、昔のバージョンではFace3 Face4というクラスがあったようです。

buildNonIndexedBufferCubeMeshでは、以下の部分でoriginalVertices facesvertexBufferに変換しています。

  const vertexBuffer = [];
  for (const face of faces) {
    const v0 = originalVertices[face[0]];
    const v1 = originalVertices[face[1]];
    const v2 = originalVertices[face[2]];
    const v3 = originalVertices[face[3]];
    vertexBuffer.push(...v0.concat(v1, v2)); // a triangle
    vertexBuffer.push(...v2.concat(v3, v0)); // a triangle
  }

マテリアル設定

各面にマテリアルを設定するためには、頂点を複数のグループに分け、グループごとにマテリアルのインデクスを設定します。コードは以下の部分です。

  const numVertices = vertexBuffer.length / 3;
  geometry.addGroup(0, numVertices / 2, 0);
  geometry.addGroup(numVertices / 2, numVertices / 2, 1);

addGroupの第一引数はグループ化する"頂点"の開始インデクスです。頂点成分や面の開始インデクスではないことにご注意ください。言い換えると、vertexBufferの添字の[addGroupの第一引数×3]番目から始まる頂点列をグループ化します。

同様に、グループ化する第二引数は頂点の個数です。第三引数はグループに割り当てるマテリアルのインデクスです。

なお、公式ドキュメントによるとグループごとに描画呼び出しが走るとのことなので、同じマテリアルの頂点は並び替えて一つのグループにまとめた方が描画が高速かもしれません。

法線設定

面の法線を計算し、その平均値を頂点法線とする場合は、computeVertexNormalsを実行します。

  geometry.computeVertexNormals();

手動で別の法線を設定したい場合はsetAttributeを利用するのではないかと思いますが、確認できていません。

なお、法線を何も設定しないとメッシュが描画されません。

頂点インデクスを用いる場合

まずはコード全体です。冒頭のCodePenでは水色と黄色の立方体の生成部分に対応します。

function buildIndexedBufferCubeMesh() {
  const geometry = new THREE.BufferGeometry();

  const vertexBuffer = originalVertices.flat();

  const vertices = new Float32Array(vertexBuffer);
  geometry.setAttribute("position", new THREE.BufferAttribute(vertices, 3));

  const indices = [];
  for (const face of faces) {
    indices.push(face[0], face[1], face[2]);
    indices.push(face[2], face[3], face[0]);
  }
  geometry.setIndex(indices);

  const numIndices = indices.length;
  geometry.addGroup(0, numIndices / 2, 0);
  geometry.addGroup(numIndices / 2, numIndices / 2, 1);

  geometry.computeVertexNormals();

  const material0 = new THREE.MeshLambertMaterial({color: 0xFFFF00});
  const material1 = new THREE.MeshLambertMaterial({color: 0x00FFFF});
  const materials = [material0, material1];
  const mesh = new THREE.Mesh(geometry, materials);
  return mesh;
}

頂点および面の設定

頂点の設定はコード内の以下の箇所です。

  const vertices = new Float32Array(vertexBuffer);
  geometry.setAttribute("position", new THREE.BufferAttribute(vertices, 3));

頂点インデクスを用いる場合は、setAttribute("position", ...)には、ジオメトリの各頂点の成分を一次元配列に潰した形状のバッファを指定します。buildIndexedBufferCubeMeshでは単にoriginalVertices.flat();とすれば十分です。

面を設定するためは、頂点のインデクスを並べた配列をsetIndexに与えます。

  const indices = [];
  for (const face of faces) {
    indices.push(face[0], face[1], face[2]);
    indices.push(face[2], face[3], face[0]);
  }
  geometry.setIndex(indices);

マテリアル設定

面のグループ化およびマテリアル設定は以下の部分です。

  const numIndices = indices.length;
  geometry.addGroup(0, numIndices / 2, 0);
  geometry.addGroup(numIndices / 2, numIndices / 2, 1);

頂点インデクスを用いる場合も、addGroupの第一引数・第二引数は頂点インデクスの個数です。言い換えると、(頂点・面が同じ形状・並びならば)頂点インデクスを用いない場合にaddGroupを呼び出す場合と同じ引数を与えるようです。

環境設定

カメラや光源を設定しているだけですが載せておきます。

function main() {
  // scene, add mesh
  var scene = new THREE.Scene();

  const nonIndexedCubeMesh = buildNonIndexedBufferCubeMesh();
  scene.add(nonIndexedCubeMesh);

  const indexedCubeMesh = buildIndexedBufferCubeMesh();
  indexedCubeMesh.position.set(30, 0, 0);
  scene.add(indexedCubeMesh);

  // camera, light, renderer
  const camera = new THREE.OrthographicCamera(-60, +60, 35, -35, 1, 1000);
  camera.position.set(-90, -90, 60);
  camera.up.set(1, 1, 1);
  camera.lookAt(new THREE.Vector3(0, 0, 0));
  scene.add(camera);

  const directionalLight = new THREE.DirectionalLight(0xffffff, 0.5);
  directionalLight.position.set(-10, 15, 50);
  scene.add(directionalLight);
  
  const directionalLightTarget = new THREE.Object3D();
  scene.add(directionalLightTarget);
  directionalLight.target = directionalLightTarget;

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

  var renderer = new THREE.WebGLRenderer();
  renderer.setSize(window.innerWidth, window.innerHeight);
  document.body.appendChild(renderer.domElement);
  renderer.render(scene, camera);
}

main();

将来課題

UVの設定など、この記事で説明していない要素がまだたくさん残っていますので、解決したら記事を更新しようと思います。