BufferGeometry
は全てのジオメトリを表現する方法です。
BufferGeometryはBufferAttribute
を使います。1つのBufferAttribute
はジオメトリを作るための1種類のデータに対応しています。vertexの位置情報を格納するためのBufferAttribute
、color情報を格納するためのBufferAttribute
、normal情報を格納するためのBufferAttribute
がそれぞれあります。
上の図ではposition
, normal
, color
, uv
それぞれのattribute情報を格納したBufferAttribute
を表しています。これらは並列な配列です。並列な配列というのはN番目にあるデータはN番目のvertexに対応しており、それがattributeの数だけあるという意味です。図ではindex=4のattributeがハイライトされています。
上の図のハイライトされたvertexには、このvertexに接する全ての面に異なるnormalが必要です。normalとはどの方向を向いているかの情報です。 この図ではnormalは角の頂点の周りの矢印で示されており、その頂点に接する全ての面には異なる方向を指すnormalが必要です。
同様に面ごとに違うUVも必要です。 UVはテクスチャのどの部分に頂点位置が対応しているか指定するテクスチャ座標です。 緑の面はFテクスチャの右上に対応するUV、青い面は左上に対応するUV、赤の面は左下に対応したUVが必要な事が分かります。
単一のvertexはこれらの情報の合成として表現されます。 頂点が異なる部分を必要とする場合、それは異なる頂点でなければなりません。
簡単な例としてBufferGeometry
を使って立方体を作ってみましょう。立方体を例にするのはvertexがfaceによって共有されているように見えて実は共有されていないからです。この例ではまずすべてのvertexの情報をリストアップして並列の配列に変換してBufferAttribute
を作り、最後にBufferGeometry
を作ります。
立方体に必要な情報をすべてリストアップします。Geometry
では1つのvertexを複数のfaceで共有できましたが今回は共有できないことに注意してください。つまり1つの立方体を作るために36個のvertexが必要になります。1つの面につき2つの三角形、1つの三角形につき3つのvertex、これが6面あるので36個のvertexが必要になる計算です。
const vertices = [ // front { pos: [-1, -1, 1], norm: [ 0, 0, 1], uv: [0, 0], }, { pos: [ 1, -1, 1], norm: [ 0, 0, 1], uv: [1, 0], }, { pos: [-1, 1, 1], norm: [ 0, 0, 1], uv: [0, 1], }, { pos: [-1, 1, 1], norm: [ 0, 0, 1], uv: [0, 1], }, { pos: [ 1, -1, 1], norm: [ 0, 0, 1], uv: [1, 0], }, { pos: [ 1, 1, 1], norm: [ 0, 0, 1], uv: [1, 1], }, // right { pos: [ 1, -1, 1], norm: [ 1, 0, 0], uv: [0, 0], }, { pos: [ 1, -1, -1], norm: [ 1, 0, 0], uv: [1, 0], }, { pos: [ 1, 1, 1], norm: [ 1, 0, 0], uv: [0, 1], }, { pos: [ 1, 1, 1], norm: [ 1, 0, 0], uv: [0, 1], }, { pos: [ 1, -1, -1], norm: [ 1, 0, 0], uv: [1, 0], }, { pos: [ 1, 1, -1], norm: [ 1, 0, 0], uv: [1, 1], }, // back { pos: [ 1, -1, -1], norm: [ 0, 0, -1], uv: [0, 0], }, { pos: [-1, -1, -1], norm: [ 0, 0, -1], uv: [1, 0], }, { pos: [ 1, 1, -1], norm: [ 0, 0, -1], uv: [0, 1], }, { pos: [ 1, 1, -1], norm: [ 0, 0, -1], uv: [0, 1], }, { pos: [-1, -1, -1], norm: [ 0, 0, -1], uv: [1, 0], }, { pos: [-1, 1, -1], norm: [ 0, 0, -1], uv: [1, 1], }, // left { pos: [-1, -1, -1], norm: [-1, 0, 0], uv: [0, 0], }, { pos: [-1, -1, 1], norm: [-1, 0, 0], uv: [1, 0], }, { pos: [-1, 1, -1], norm: [-1, 0, 0], uv: [0, 1], }, { pos: [-1, 1, -1], norm: [-1, 0, 0], uv: [0, 1], }, { pos: [-1, -1, 1], norm: [-1, 0, 0], uv: [1, 0], }, { pos: [-1, 1, 1], norm: [-1, 0, 0], uv: [1, 1], }, // top { pos: [ 1, 1, -1], norm: [ 0, 1, 0], uv: [0, 0], }, { pos: [-1, 1, -1], norm: [ 0, 1, 0], uv: [1, 0], }, { pos: [ 1, 1, 1], norm: [ 0, 1, 0], uv: [0, 1], }, { pos: [ 1, 1, 1], norm: [ 0, 1, 0], uv: [0, 1], }, { pos: [-1, 1, -1], norm: [ 0, 1, 0], uv: [1, 0], }, { pos: [-1, 1, 1], norm: [ 0, 1, 0], uv: [1, 1], }, // bottom { pos: [ 1, -1, 1], norm: [ 0, -1, 0], uv: [0, 0], }, { pos: [-1, -1, 1], norm: [ 0, -1, 0], uv: [1, 0], }, { pos: [ 1, -1, -1], norm: [ 0, -1, 0], uv: [0, 1], }, { pos: [ 1, -1, -1], norm: [ 0, -1, 0], uv: [0, 1], }, { pos: [-1, -1, 1], norm: [ 0, -1, 0], uv: [1, 0], }, { pos: [-1, -1, -1], norm: [ 0, -1, 0], uv: [1, 1], }, ];
次にこれを3つの並列な配列に変換します。 (訳註:並列な配列parallel arraysとは例えば頂点を指定する配列と色を指定する配列があり1つの頂点をレンダリングするために2つの配列の同じインデックスの要素を指定するような使われ方をする配列のことです。次の例ではpositions, normals, uvsの3つの配列が並列の配列として使われています)
const positions = []; const normals = []; const uvs = []; for (const vertex of vertices) { positions.push(...vertex.pos); normals.push(...vertex.norm); uvs.push(...vertex.uv); }
最後にそれぞれの配列に対してBufferAttribute
を作りBufferGeometry
に指定します。
const geometry = new THREE.BufferGeometry(); const positionNumComponents = 3; const normalNumComponents = 3; const uvNumComponents = 2; geometry.setAttribute( 'position', new THREE.BufferAttribute(new Float32Array(positions), positionNumComponents)); geometry.setAttribute( 'normal', new THREE.BufferAttribute(new Float32Array(normals), normalNumComponents)); geometry.setAttribute( 'uv', new THREE.BufferAttribute(new Float32Array(uvs), uvNumComponents));
名前の付け方に注意してください。three.jsで決められている名前以外を指定することはできません(カスタムシェーダーを使用する場合は別です)。position
, normal
, uv
はthree.jsで決められている名前です。ここでは指定していませんがcolor
も指定可能です。
上の例ではpositions
, normals
, uvs
の3つのJavaScriptのネイティブ配列を作りました。次にFloat32Array
型のTypedArraysに変換します。BufferAttribute
はネイティブ配列ではなくTypedArrayである必要があります。さらにそれぞれのBufferAttribute
に対して「1つのvertexに対していくつの要素が必要か」を指定する必要があります。例えばpositionやnormalsは3次元なので1つのvertexつき3つの要素を必要とします。UVはテクスチャ上の2次元の点なので2つの要素を必要とします。
かなり大量のデータです。この配列からvertexを選ぶときにはインデックスを使います。1つの三角形は3つのvertexで構成されていて2つの三角形が1つのfaceを作っています。これが6枚で1つの立方体を構成しています。1つのfaceを構成する2つの三角形を作っているvertexは2つが同じデータを持っています。position, normal, UVすべて同じです。そこで重複しているデータを1つ消して1つにして、そのデータを別のインデックスで指定します。
ではまず重複したデータを1つにします。
const vertices = [ // front { pos: [-1, -1, 1], norm: [ 0, 0, 1], uv: [0, 0], }, // 0 { pos: [ 1, -1, 1], norm: [ 0, 0, 1], uv: [1, 0], }, // 1 { pos: [-1, 1, 1], norm: [ 0, 0, 1], uv: [0, 1], }, // 2 - - { pos: [-1, 1, 1], norm: [ 0, 0, 1], uv: [0, 1], }, - { pos: [ 1, -1, 1], norm: [ 0, 0, 1], uv: [1, 0], }, { pos: [ 1, 1, 1], norm: [ 0, 0, 1], uv: [1, 1], }, // 3 // right { pos: [ 1, -1, 1], norm: [ 1, 0, 0], uv: [0, 0], }, // 4 { pos: [ 1, -1, -1], norm: [ 1, 0, 0], uv: [1, 0], }, // 5 - - { pos: [ 1, 1, 1], norm: [ 1, 0, 0], uv: [0, 1], }, - { pos: [ 1, -1, -1], norm: [ 1, 0, 0], uv: [1, 0], }, { pos: [ 1, 1, 1], norm: [ 1, 0, 0], uv: [0, 1], }, // 6 { pos: [ 1, 1, -1], norm: [ 1, 0, 0], uv: [1, 1], }, // 7 // back { pos: [ 1, -1, -1], norm: [ 0, 0, -1], uv: [0, 0], }, // 8 { pos: [-1, -1, -1], norm: [ 0, 0, -1], uv: [1, 0], }, // 9 - - { pos: [ 1, 1, -1], norm: [ 0, 0, -1], uv: [0, 1], }, - { pos: [-1, -1, -1], norm: [ 0, 0, -1], uv: [1, 0], }, { pos: [ 1, 1, -1], norm: [ 0, 0, -1], uv: [0, 1], }, // 10 { pos: [-1, 1, -1], norm: [ 0, 0, -1], uv: [1, 1], }, // 11 // left { pos: [-1, -1, -1], norm: [-1, 0, 0], uv: [0, 0], }, // 12 { pos: [-1, -1, 1], norm: [-1, 0, 0], uv: [1, 0], }, // 13 - - { pos: [-1, 1, -1], norm: [-1, 0, 0], uv: [0, 1], }, - { pos: [-1, -1, 1], norm: [-1, 0, 0], uv: [1, 0], }, { pos: [-1, 1, -1], norm: [-1, 0, 0], uv: [0, 1], }, // 14 { pos: [-1, 1, 1], norm: [-1, 0, 0], uv: [1, 1], }, // 15 // top { pos: [ 1, 1, -1], norm: [ 0, 1, 0], uv: [0, 0], }, // 16 { pos: [-1, 1, -1], norm: [ 0, 1, 0], uv: [1, 0], }, // 17 - - { pos: [ 1, 1, 1], norm: [ 0, 1, 0], uv: [0, 1], }, - { pos: [-1, 1, -1], norm: [ 0, 1, 0], uv: [1, 0], }, { pos: [ 1, 1, 1], norm: [ 0, 1, 0], uv: [0, 1], }, // 18 { pos: [-1, 1, 1], norm: [ 0, 1, 0], uv: [1, 1], }, // 19 // bottom { pos: [ 1, -1, 1], norm: [ 0, -1, 0], uv: [0, 0], }, // 20 { pos: [-1, -1, 1], norm: [ 0, -1, 0], uv: [1, 0], }, // 21 - - { pos: [ 1, -1, -1], norm: [ 0, -1, 0], uv: [0, 1], }, - { pos: [-1, -1, 1], norm: [ 0, -1, 0], uv: [1, 0], }, { pos: [ 1, -1, -1], norm: [ 0, -1, 0], uv: [0, 1], }, // 22 { pos: [-1, -1, -1], norm: [ 0, -1, 0], uv: [1, 1], }, // 23 ];
はい、24個になりました。これに対して36個のインデックスを指定して36個のvertexを作ります。BufferGeometry.setIndex
により36個のインデックスを使って12個の三角形を作ります。
geometry.setAttribute( 'position', new THREE.BufferAttribute(positions, positionNumComponents)); geometry.setAttribute( 'normal', new THREE.BufferAttribute(normals, normalNumComponents)); geometry.setAttribute( 'uv', new THREE.BufferAttribute(uvs, uvNumComponents)); +geometry.setIndex([ + 0, 1, 2, 2, 1, 3, // front + 4, 5, 6, 6, 5, 7, // right + 8, 9, 10, 10, 9, 11, // back + 12, 13, 14, 14, 13, 15, // left + 16, 17, 18, 18, 17, 19, // top + 20, 21, 22, 22, 21, 23, // bottom +]);
Geometry
と同じようにBufferGeometry
もcomputeVertexNormals
メソッドを持っています。これは特に指定がない場合に自動的にnormalを計算するメソッドです。ただしGeometry
の場合と違いvertexがfaceによって共有されていないためにcomputeVertexNormals
の結果も少し違います。
シリンダーでcomputeVertexNormals
の違いを比較してみましょう。よく見ると左のシリンダーには縫い目が見えると思います。これはvertexを共有することができないためにUVも異なるためです。ちょっとしたことですが、気になるときは自分でnormalを指定すれば良いだけです。
ネイティブの配列を使う代わりにTypedArraysを使うこともできます。TypedArrayは最初に配列の大きさを指定する必要があるため少し面倒です。ネイティブの配列はpush
で追加してlength
で配列の長さを確認することができます。TypedArrayにはpush
メソッドがないのであらかじめ用意した配列に注意しながら要素を入れていく必要があります。
この例では最初に大きなデータを使っているので配列の長さを意識することはそれほど大変ではありません。
-const positions = []; -const normals = []; -const uvs = []; +const numVertices = vertices.length; +const positionNumComponents = 3; +const normalNumComponents = 3; +const uvNumComponents = 2; +const positions = new Float32Array(numVertices * positionNumComponents); +const normals = new Float32Array(numVertices * normalNumComponents); +const uvs = new Float32Array(numVertices * uvNumComponents); +let posNdx = 0; +let nrmNdx = 0; +let uvNdx = 0; for (const vertex of vertices) { - positions.push(...vertex.pos); - normals.push(...vertex.norm); - uvs.push(...vertex.uv); + positions.set(vertex.pos, posNdx); + normals.set(vertex.norm, nrmNdx); + uvs.set(vertex.uv, uvNdx); + posNdx += positionNumComponents; + nrmNdx += normalNumComponents; + uvNdx += uvNumComponents; } geometry.setAttribute( 'position', - new THREE.BufferAttribute(new Float32Array(positions), positionNumComponents)); + new THREE.BufferAttribute(positions, positionNumComponents)); geometry.setAttribute( 'normal', - new THREE.BufferAttribute(new Float32Array(normals), normalNumComponents)); + new THREE.BufferAttribute(normals, normalNumComponents)); geometry.setAttribute( 'uv', - new THREE.BufferAttribute(new Float32Array(uvs), uvNumComponents)); + new THREE.BufferAttribute(uvs, uvNumComponents)); geometry.setIndex([ 0, 1, 2, 2, 1, 3, // front 4, 5, 6, 6, 5, 7, // right 8, 9, 10, 10, 9, 11, // back 12, 13, 14, 14, 13, 15, // left 16, 17, 18, 18, 17, 19, // top 20, 21, 22, 22, 21, 23, // bottom ]);
TypedArrayはプログラムが走っている状態でvertexの編集をしたいときに便利です。
良い例が思いつかないのでとりあえずメッシュの四角形が出たり入ったりする球体を作ってみます。
球体の位置とindexを生成するコードです。四角形の中でvertexを共有していますが四角形と四角形でvertexを共有することはありません。共有してしまうと1つの四角形が出たり入ったりするたびに隣の四角形が移動してしまいます。今回は別々に移動させたいのでそうしています。
面倒なので3つのObject3D
階層を用意して球体のvertexを計算します。くわしくはたくさんのオブジェクトを最適化するこの記事をご覧ください。
function makeSpherePositions(segmentsAround, segmentsDown) { const numVertices = segmentsAround * segmentsDown * 6; const numComponents = 3; const positions = new Float32Array(numVertices * numComponents); const indices = []; const longHelper = new THREE.Object3D(); const latHelper = new THREE.Object3D(); const pointHelper = new THREE.Object3D(); longHelper.add(latHelper); latHelper.add(pointHelper); pointHelper.position.z = 1; const temp = new THREE.Vector3(); function getPoint(lat, long) { latHelper.rotation.x = lat; longHelper.rotation.y = long; longHelper.updateMatrixWorld(true); return pointHelper.getWorldPosition(temp).toArray(); } let posNdx = 0; let ndx = 0; for (let down = 0; down < segmentsDown; ++down) { const v0 = down / segmentsDown; const v1 = (down + 1) / segmentsDown; const lat0 = (v0 - 0.5) * Math.PI; const lat1 = (v1 - 0.5) * Math.PI; for (let across = 0; across < segmentsAround; ++across) { const u0 = across / segmentsAround; const u1 = (across + 1) / segmentsAround; const long0 = u0 * Math.PI * 2; const long1 = u1 * Math.PI * 2; positions.set(getPoint(lat0, long0), posNdx); posNdx += numComponents; positions.set(getPoint(lat1, long0), posNdx); posNdx += numComponents; positions.set(getPoint(lat0, long1), posNdx); posNdx += numComponents; positions.set(getPoint(lat1, long1), posNdx); posNdx += numComponents; indices.push( ndx, ndx + 1, ndx + 2, ndx + 2, ndx + 1, ndx + 3, ); ndx += 4; } } return {positions, indices}; }
こんな感じです。
const segmentsAround = 24; const segmentsDown = 16; const {positions, indices} = makeSpherePositions(segmentsAround, segmentsDown);
returnされているpositionは単位球(半径が1の球体)なのでそのままこのデータをnormalに使えます。
const normals = positions.slice();
attributeも設定しましょう。
const geometry = new THREE.BufferGeometry(); const positionNumComponents = 3; const normalNumComponents = 3; +const positionAttribute = new THREE.BufferAttribute(positions, positionNumComponents); +positionAttribute.setUsage(THREE.DynamicDrawUsage); geometry.setAttribute( 'position', + positionAttribute); geometry.setAttribute( 'normal', new THREE.BufferAttribute(normals, normalNumComponents)); geometry.setIndex(indices);
position attributeに対する参照を保存しています。dynamicに指定しているところも注意が必要です。これはTHREE.jsに「これからこのattributeは変更が加えられる」ことを教えます。renderループではpositionを毎度アップデートします。
const temp = new THREE.Vector3(); ... for (let i = 0; i < positions.length; i += 3) { const quad = (i / 12 | 0); const ringId = quad / segmentsAround | 0; const ringQuadId = quad % segmentsAround; const ringU = ringQuadId / segmentsAround; const angle = ringU * Math.PI * 2; temp.fromArray(normals, i); temp.multiplyScalar(THREE.MathUtils.lerp(1, 1.4, Math.sin(time + ringId + angle) * .5 + .5)); temp.toArray(positions, i); } positionAttribute.needsUpdate = true;
最後にpositionAttribute.needsUpdate
を設定してTHREE.jsに変更が必要であることを伝えます。
BufferGeometry
を作ってBufferAttribute
をアップデートする方法を紹介しました。