glTFのメッシュを表示してみよう

今回は少し踏み込んで、より実践的な内容です。3Dモデルファイルからメッシュ情報を読み込んで、表示してみましょう。

近年、急速に普及が進んでいるglTFという3Dファイルフォーマットがあります。これは主にWeb上での3Dコンテンツの流通を目的にKhronosという業界標準団体が策定しているフォーマットで、JavaScriptやWebGLと高い親和性があります。このSpinelライブラリではglTFを読み込んで3Dモデルを表示することをやってみましょう。

glTFファイルフォーマットについては、別ページで詳しく紹介しています。
glTFの仕様書も公開されており、GithubのglTFプロジェクトページ で読むことができます。

このglTFフォーマットでは、拡張子が.gltfというテキストファイルにJSON形式で3Dモデルのメタ情報が、別ファイルの.binというバイナリファイルにポリゴン情報の実データが入っています。

JSONデータを取得しよう

まずは、.gitfファイルの方を読み込んで、JSONデータを得るところまでやってみましょう。

Gltf2Importer.tsという以下の内容のファイルを作成します。

// Gltf2Importer.ts export default class Gltf2Importer { private static __instance: Gltf2Importer; private constructor() {} async import(uri: string) { let response: Response; try { response = await fetch(uri); } catch (err) { console.log('glTF2 load error.', err); }; // fetch関数のレスポンスのデータをArrayBufferに変換します。 const arrayBuffer = await response!.arrayBuffer(); this._loadFromArrayBuffer(arrayBuffer); } // ArrayBufferから文字列に変換する関数です。 // こういう処理はいろいろネットを調べて少しずつ作っていったり、発見するものですので、 // 「こんなコード思いつかないよ!」なんて落ち込まないでくださいね。地道に続ければどうにかなるものです。 private _arrayBufferToString(arrayBuffer: ArrayBuffer) { if (typeof TextDecoder !== 'undefined') { let textDecoder = new TextDecoder(); return textDecoder.decode(arrayBuffer); } else { let bytes = new Uint8Array(arrayBuffer); let result = ""; let length = bytes.length; for (let i = 0; i < length; i++) { result += String.fromCharCode(bytes[i]); } return result; } } private _loadFromArrayBuffer(arrayBuffer: ArrayBuffer) { const gotText = this._arrayBufferToString(arrayBuffer); // 文字列をJSONとしてパースします。 const json = JSON.parse(gotText); console.log(json); } // このインスタンスの生成・取得方法については「シングルトンパターン」で調べてみてください。 static getInstance() { if (!this.__instance) { this.__instance = new Gltf2Importer(); } return this.__instance; } }

クラスを追加したので、あわせてindex.tsも更新します。

// index.ts import Context from "./Context.js"; import Material from "./Material.js"; import Mesh from "./Mesh.js"; import Gltf2Importer from "./glTF2Importer.js"; // <-- 追加 const Spinel = { Context, Material, Mesh Gltf2Importer, // <-- 追加 }; export default Spinel;

サンプル側のmain.tsで、Gltf2Importerを使ってglTFファイルを読み出します。

// main.ts // ファイル中の適当な箇所に以下の2行を追加しましょう。 const glTF2Importer = Spinel.Gltf2Importer.getInstance(); glTF2Importer.import('../assets/gltf/BoxAnimated/glTF/BoxAnimated.gltf');

あらかじめ、プロジェクトにassets/gltf/というフォルダ階層を作り、その中にCreative CommonsライセンスのサンプルglTFファイルであるBoxAnimated.gltfを配置してあります(GithubのSpinelライブラリのリポジトリに含まれています)。

さて、実行してみましょう。

image.png

ちゃんとJSONデータが取れていますね。

.binファイルから頂点データを取得しよう

さて、次はいよいよ.binファイルから頂点データを抜き出します。その前に、入念に準備したいことがあります。.gltfファイルはJSONですが、どういうプロパティが存在するのか、というフォーマットはあらかじめ決まっています。ということは、glTF JSONフォーマットの型を定義すれば便利になりそうです。

glTF.tsというファイルを、以下の内容で作成してください。これは、glTFフォーマットの仕様ページを参考に作成したものです。

// glTF2.ts export type Gltf2Scene = { nodes?: number[], name?: string, extensions: Object, extras?: any } export type Gltf2Attribute = { POSITION: number, NORMAL?: number, TANGENT?: number, TEXCOORD_0?: number, TEXCOORD_1?: number, COLOR_0?: number, JOINTS_0?: number, WEIGHTS_0?: number } export type Gltf2Primitive = { attributes: Gltf2Attribute, indices?: number, material?: number, mode?: number, targets?: Object[], extensions?: Object, extras?: any } export type Gltf2Mesh = { primitives: Gltf2Primitive[], weights?: number[], name?: string, extensions: Object, extras?: any } export type Gltf2Node = { camera?: number, children?: number[], skin?: number, matrix?: number[], mesh?: number, rotation?: number[], scale?: number[], translation?: number[], weights?: number[], name?: string, extensions?: Object, extras?: any } export type Gltf2Skin = { inverseBindMatrices?: number, skeleton?: number, joints: number[], name?: string, extensions?: Object, extras?: any } export type Gltf2TextureInfo = { index: number, texCoord?: number, extensions?: Object, extras?: any } export type Gltf2OcclusionTextureInfo = { index: number, texCoord?: number, strength?: number, extensions?: Object, extras?: any } export type Gltf2NormalTextureInfo = { index: number, texCoord?: number, scale?: number, extensions?: Object, extras?: any } export type Gltf2PbrMetallicRoughness = { baseColorFactor?: number[], baseColorTexture?: Gltf2TextureInfo, metallicFactor?: number, roughnessFactor?: number, metallicRoughnessTexture?: Gltf2TextureInfo, extensions?: Object, extras?: any } export type Gltf2Material = { pbrMetallicRoughness?: Object, normalTexture?: Gltf2NormalTextureInfo, occlusionTexture? : Gltf2OcclusionTextureInfo, emissiveTexture?: Gltf2TextureInfo, emissiveFactor?: number[], alphaMode?: string, alphaCutoff?: number, doubleSided?: boolean, name?: string, extensions?: Object, extras?: any } export type Gltf2CameraOrthographic = { xmag: number, ymag: number, zfar: number, znear: number, extensions?: Object, extras?: any } export type Gltf2CameraPerspective = { aspectRatio?: number, yfov: number, zfar?: number, znear: number, extensions?: Object, extras?: any } export type Gltf2Camera = { orthographic?: Gltf2CameraOrthographic, perspective?: Gltf2CameraPerspective, type: string, name?: string, extensions?: Object, extras?: any } export type Gltf2Image = { uri?: string, mimeType?: string, bufferView?: number, name?: string, extensions?: Object, extras?: any } export type Gltf2AnimationChannelTarget = { node?: number, path: string, extensions?: Object, extras?: any } export type Gltf2AnimationChannel = { sampler: number, target: Gltf2AnimationChannelTarget, extensions?: Object, extras?: any } export type Gltf2AnimationSampler = { input: number, interpolation?: string, output: number, extensions?: Object, extras?: any } export type Gltf2Animation = { channels: Gltf2AnimationChannel, samplers: Gltf2AnimationSampler, name?: string, extensions?: Object, extras?: any } export type Gltf2Texture = { sampler?: number, source?: number, name?: string, extensions?: Object, extras?: any } export type Gltf2Sampler = { magFilter?: number, minFilter?: number, wrapS?: number, wrapT?: number, name?: string, extensions?: Object, extras?: any } export type Gltf2SparseValues = { bufferView: number, byteOffset?: number, extensions?: Object, extras?: any } export type Gltf2SparseIndices = { bufferView: number, byteOffset?: number, componentType: number, extensions?: Object, extras?: any } export type Gltf2Sparse = { count: number, indices?: Gltf2SparseIndices, values?: Gltf2SparseValues, extensions?: Object, extras?: any } export type Gltf2Accessor = { bufferView?: number, byteOffset?: number, componentType: number, normalized?: boolean, count: number, type: string, max?: number[], min?: number[], sparse?: Object, name?: string, extensions?: Object, extras?: any } export type Gltf2BufferView = { buffer: number, byteOffset?: number, byteLength: number, byteStride?: boolean, target: number, name?: string, extensions?: Object, extras?: any } export type Gltf2Buffer = { uri?: string, byteLength: number, name?: string, extensions?: Object, extras?: any } export type Gltf2Asset = { copyright?: string, generater?: string, version: string, minVersion?: string, extensions?: any, extras?: any } export type Gltf2 = { asset: Gltf2Asset, buffers: Gltf2Buffer[], scenes: Gltf2Scene[], scene: number, meshes: Gltf2Mesh[], nodes: Gltf2Node[], skins: Gltf2Skin[], materials: Gltf2Material[], cameras: Gltf2Camera[], images: Gltf2Image[], animations: Gltf2Animation[], textures: Gltf2Texture[], samplers: Gltf2Sampler[], accessors: Gltf2Accessor[], bufferViews: Gltf2BufferView[], extensionsUsed?: string[], extensions?: string[] };

これで、VSCodeなど、入力補完ができるエディタではglTFのJSONプロパティの入力においても入力が補完されるようになります。

実装を進めましょう。次に、.binファイルから頂点データのバイト列を取り出します。これ自体はそんなに難しくありません。

BoxAnimated.gltfの中を開くと、"buffers"プロパティには以下のような値が入っています。

// BoxAnimated.gltf ... "buffers": [ { "byteLength": 9308, "uri": "BoxAnimated0.bin" } ] ....

ですので、以下のようにコード実装すれば.binファイルにアクセスすることができそうです。

// Gltf2Importer.ts private async _loadBin(json: Gltf2, uri: string) { // gltf2Impoert.import()メソッドに渡されたgltfファイルのURIから、ベースパス(.gltfや.binファイルがあるフォルダパス)を取得します。 const basePath = uri.substring(0, uri.lastIndexOf('/')) + '/'; // json.buffers配列から0番目のbufferオブジェクトを取り出す(大抵は配列の中に1つしかありません) const bufferInfo = json.buffers[0]; // bufferオブジェクトのuriプロパティに.binファイルへの相対パスが入っているので、 const splitted = bufferInfo.uri!.split('/'); // ファイル名部分を取り出します。 const filename = splitted[splitted.length - 1]; // ベースパスに.binファイル名を加えることで、.binファイルのURIになります。そのURLをfetchすることで、.binファイルを読み込みます。 const response = await fetch(basePath + filename); // 読み込んだバイト列をArrayBufferとして取り出します。 const arrayBufferBin = await response.arrayBuffer(); console.log(arrayBufferBin); return arrayBufferBin; }

実行してみましょう。

image.png

ちゃんと.binファイルからArrayBufferが取れていますね。
さて、このArrayBufferにすべての頂点情報が含まれています。ここから、いくつもある各種の頂点データや頂点インデックスデータを適切に取り出す必要があります。そのためには、適切なバイト位置や頂点データについての情報が必要です。

それらのデータは、glTF JSONデータのPrimitiveオブジェクト、Accsessorオブジェクト、BufferViewオブジェクトにあります。
それぞれのJSON部分を見てみましょう。

... "meshes": [ { "primitives": [ { "attributes": { "NORMAL": 1, "POSITION": 2 }, "indices": 0, "mode": 4, "material": 0 } ], "name": "inner_box" }, { "primitives": [ { ...
... "accessors": [ { "bufferView": 0, "byteOffset": 0, "componentType": 5123, "count": 186, "max": [ 95 ], "min": [ 0 ], "type": "SCALAR" }, ...
... "bufferViews": [ { "buffer": 0, "byteOffset": 7784, "byteLength": 1524, "target": 34963 }, ...

これらの情報をたどって、全体のArrayBufferから情報を切り出す必要があります。ここでは、各メッシュのプリミティブ(メッシュを構成するポリゴン情報のひとまとまりで、WebGLにおける最小の描画単位です。)の0番目を取り出してみましょう(いずれ、1番目以降にも対応できるようにします)。Accessor, BufferViewから各種情報を取り出し、Float32ArrayとしてArrayBufferから一部を切り出す範囲を求めます。

// Gltf2Importer.ts private async _loadBin(json: Gltf2, uri: string) { //Set the location of gltf file as basePath const basePath = uri.substring(0, uri.lastIndexOf('/')) + '/'; const bufferInfo = json.buffers[0]; const splitted = bufferInfo.uri!.split('/'); const filename = splitted[splitted.length - 1]; const response = await fetch(basePath + filename); const arrayBufferBin = await response.arrayBuffer(); return arrayBufferBin; } private _componentBytes(componentType: number) { switch (componentType) { // 以下の数値は、実はWebGL/OpenGLにおけるenum定数です case 5123: // UNSIGNED_SHORT return 2; case 5125: // UNSINGED_INT return 4; case 5126: // FLOAT return 4; default: console.error('Unsupported ComponentType.'); return 0; } } private _componentTypedArray(componentType: number) { switch (componentType) { case 5123: // UNSIGNED_SHORT return Uint16Array; case 5125: // UNSINGED_INT return Uint32Array; case 5126: // FLOAT return Float32Array; default: console.error('Unsupported ComponentTypedArray.'); return Uint8Array; } } private _componentNum(type: string) { switch (type) { case 'SCALAR': return 1; case 'VEC2': return 2; case 'VEC3': return 3; case 'VEC4': return 4; case 'MAT3': return 9; case 'MAT4': return 16; default: console.error('Unsupported Type.'); return 0; } } private _loadMesh(arrayBufferBin: ArrayBuffer, json: Gltf2) { const meshes: Mesh[] = [] // 全てのメッシュについてループします for (let mesh of json.meshes) { // メッシュの0番目のプリミティブの情報を取得します。 const primitive = mesh.primitives[0]; // プリミティブの持つ頂点属性(アトリビュート)の中から位置座標に相当するAccsessor情報を取り出します。 const attributes = primitive.attributes; const positionAccessor = json.accessors[attributes.POSITION] as Gltf2Accessor; // 頂点座標のAccsessorが属しているBufferViewの情報を取り出します。 const positionBufferView = json.bufferViews[positionAccessor.bufferView!] as Gltf2BufferView; // BufferViewの、自身が属するBufferの先頭からのメモリ位置(オフセット)を取得します const byteOffsetOfBufferView = positionBufferView.byteOffset!; // Accsessorの、自身が属するBufferViewの先頭からのメモリ位置(オフセット)を取得します const byteOffsetOfAccessor = positionAccessor.byteOffset!; // 両方を足すことで、Buffer(ArrayBuffer)先頭からのオフセットが求まります。 const byteOffset = byteOffsetOfBufferView + byteOffsetOfAccessor; // 頂点属性のコンポーネントの型を取得します(16ビットの正整数か、32ビット正整数か、32ビット浮動小数点か) const positionComponentBytes = this._componentBytes(positionAccessor.componentType); // 頂点座標のコンポーネントの数を取得します(x,y,zなら3つ、x,y,z,wなら4つです) const positionComponentNum = this._componentNum(positionAccessor.type); // 頂点の数を取得します const count = positionAccessor.count; // TypedArray(今回の場合はFloat32Array)の第3引数に与える、Float型数値の個数を求めます。 const typedArrayComponentCount = positionComponentNum * count; // コンポーネントの型に合わせたTypedArray(Uint16Array/Uint32Array/Float32Array)のクラスオブジェクトを取得します const positionTypedArrayClass = this._componentTypedArray(positionAccessor.componentType); // 動的に得た種類のTypedArrayを、頂点データ全体のArrayBufferのうち、指定したオフセットとデータ個数で一部を切り出す形で生成します。 const positionTypedArray = new positionTypedArrayClass(arrayBufferBin, byteOffset, typedArrayComponentCount); console.log(positionTypedArray); } }

実行してみましょう。

image.png

2つのFloat32Arrayが表示されました。ということは、メッシュが2つあるようですね。

(念の為ですが、左側の三角形は以前の回でのコード処理による三角形描画なので、今回のglTF読み込みとは関係ありません)

長くなりそうですので、今回は一旦ここまでとしておきましょう。

この時点のプロジェクトコードはこちら で公開しています。

6日目:色んな種類のポリゴンデータを表示できるようにする

8日目:glTFから読み込んだポリゴンデータを表示する