glTFのマテリアルを描画に反映しよう

つぎは、glTFのマテリアルを描画に反映してみましょう。

今サンプルに使っているglTFファイルは立方体が2つあり、それぞれ別の色がマテリアル色として設定されています。

マテリアル情報を読み込むことによって、その色を描画に反映させましょう。

その前座として、まず準備したいことがあります。

その前の準備。ベクトルクラスを作ろう

CGにおいて、位置座標(XYZ)や色(RGBA)など、ひとまとめにして表現した方が都合が良いことが多くあります。

これらは、数学的にはベクトルと呼ばれます。これらを表現するクラスを作りましょう。

// Vector3.ts export default class Vector3 { private v: Float32Array; constructor(x: number, y: number, z: number) { this.v = new Float32Array([x, y, z]); } get x() { return this.v[0]; } set x(value: number) { this.v[0] = value; } get y() { return this.v[1]; } set y(value: number) { this.v[1] = value; } get z() { return this.v[2]; } set z(value: number) { this.v[2] = value; } get raw() { return this.v; } }
// Vector4.ts export default class Vector4 { private v: Float32Array; constructor(x: number, y: number, z: number, w: number) { this.v = new Float32Array([x, y, z, w]); } get x() { return this.v[0]; } set x(value: number) { this.v[0] = value; } get y() { return this.v[1]; } set y(value: number) { this.v[1] = value; } get z() { return this.v[2]; } set z(value: number) { this.v[2] = value; } get w() { return this.v[3]; } set w(value: number) { this.v[3] = value; } get raw() { return this.v; } }

x, y, zそれぞれをnumber型ではなく、Float32Arrayで一気に3つ分固定配列として確保し、それぞれのgetterでFloat32Arrayに添え字アクセスしているところがポイントです。

こうする理由は、WebGLに設定値を渡す場合、大抵は通常の配列ではなくFloat32Arrayなどの固定配列(JavaScriptの通常の配列と違い、メモリ的に連続していることが保証されています)で渡すことが多いことに起因しています。WebGLに設定するたびにFloat32Arrayに変換するよりも、最初からそうしていた方がパフォーマンス的にも有利です。

今回の色の情報は、RGBに加えてアルファ(不透明度)も考慮して、Vector4クラスで表すことにします。

マテリアルクラスを拡張する

glTF2Importerクラスにマテリアル情報を読み取る処理を加える前に、まずはMaterialクラスに色を取り扱うための機能を追加しましょう。

glTF(現在主流のバージョン2)においては、色は「ベースカラー」(baseColor)という名前で扱われていますので、それに習って、MaterialクラスでもbaseColorという名前のメンバ変数を作りましょう。

※baseColorというのは、物理ベースレンダリングにおける色の呼び方なのですが、物理ベースレンダリングについてはいずれ説明します。ここでは、とにかく基本的な色の呼び方と考えてください。

さらに、WebGLのシェーダープログラムに対して、自身のbaseColorの色情報を設定する機能もつけましょう。

// Material.ts import { ShaderType, WebGLProgram } from "./definitions.js"; import Context from "./Context.js"; import Vector4 from "./Vector4.js"; // 追加 export default class Material { private _program: WebGLProgram; private _baseColor = new Vector4(1, 1, 1, 1); // 追加 constructor(context: Context, vertexShaderStr: string, fragmentShaderStr: string) { const gl = context.gl; var vertexShader = this.compileShader(gl, ShaderType.Vertex, vertexShaderStr) as WebGLShader; var fragmentShader = this.compileShader(gl, ShaderType.Fragment, fragmentShaderStr) as WebGLShader; const shaderProgram = gl.createProgram() as WebGLProgram; if (shaderProgram == null) { alert('Failed to create WebGL program.'); } gl.attachShader(shaderProgram, vertexShader); gl.attachShader(shaderProgram, fragmentShader); gl.linkProgram(shaderProgram); if (!gl.getProgramParameter(shaderProgram, gl.LINK_STATUS)) { alert("Could not initialise shaders"); } gl.useProgram(shaderProgram); shaderProgram._attributePosition = gl.getAttribLocation(shaderProgram, "a_position"); gl.enableVertexAttribArray(shaderProgram._attributePosition); shaderProgram._attributeColor = gl.getAttribLocation(shaderProgram, "a_color"); gl.enableVertexAttribArray(shaderProgram._attributeColor); shaderProgram._uniformBaseColor = gl.getUniformLocation(shaderProgram, 'u_baseColor')!; // 追加 this._program = shaderProgram; } compileShader(gl: WebGLRenderingContext, shaderType: ShaderType, shaderStr: string) { let shader: WebGLShader | null; if (shaderType == ShaderType.Vertex) { shader = gl.createShader(gl.VERTEX_SHADER); } else if (shaderType == ShaderType.Fragment) { shader = gl.createShader(gl.FRAGMENT_SHADER); } if (shader! == null) { alert('Failed to create WebGL shader.'); return null; } gl.shaderSource(shader, shaderStr); gl.compileShader(shader); if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { alert(gl.getShaderInfoLog(shader)); return null; } return shader; } get program() { return this._program } // 追加ここから setUniformValues(gl: WebGLRenderingContext) { gl.uniform4fv(this._program._uniformBaseColor, this._baseColor.raw); } set baseColor(color: Vector4) { this._baseColor = color; } get baseColor(): Vector4 { return this._baseColor; } useProgram(gl: WebGLRenderingContext) { gl.useProgram(this._program); } // 追加ここまで }
// definitions.ts ... export interface WebGLProgram { _attributePosition: number; _attributeColor: number; _uniformBaseColor: WebGLUniformLocation; // 追記 }

さらに、Materialクラスに追加したメソッドをMeshクラスで呼ぶようにします。

// Mesh.ts ... draw() { const gl = this._context.gl; this._setVertexAttribPointer(this._positionBuffer, this.material.program!._attributePosition, Mesh._positionComponentNumber); this._setVertexAttribPointer(this._colorBuffer!, this.material.program!._attributeColor, Mesh._colorComponentNumber); this._material.useProgram(gl); // 追加 this._material.setUniformValues(gl); // 追加 if (this._indexBuffer != null) { gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this._indexBuffer); gl.drawElements(gl.TRIANGLES, this._indexNumber, gl.UNSIGNED_SHORT, 0); } else { gl.drawArrays(gl.TRIANGLES, 0, this.vertexNumber); } } ...

さて、ここまできたら、Materialクラスの拡張がちゃんと機能するか確かめてみましょう。

ユーザーサイドのピクセルシェーダーにUniform変数「u_baseColor」を追加し、色の計算に使用します。これは、Materialクラスを拡張したときにgetUniformLocation関数の呼び出しで指定したのと同じ文字列です。

より汎用的に使えるライブラリにするためには、ここをライブラリ型とユーザー側があらかじめ命名規約で決め打ちにするのでなく、ユーザー側のシェーダーがどのような変数名にしていても良きに計らってくれるよう、何らかの工夫をしたいところです。しかし、本記事のSpinelライブラリはシンプルさを重視していますので、決め打ちで良いことにしてしまいましょう。

さらに、マテリアルのbaseColorに赤色を指定しています。

// samples/main.ts ... const fragmentShaderStr = ` precision highp float; varying vec4 v_color; uniform vec4 u_baseColor; // 追加 void main(void) { gl_FragColor = v_color * u_baseColor; // 変更 } `; async function main() { const canvas = document.getElementById('world') as HTMLCanvasElement; const context = new Spinel.Context(canvas); const material = new Spinel.Material(context, vertexShaderStr, fragmentShaderStr); material.baseColor = new Vector4(1, 0, 0, 1); // 追加 const glTF2Importer = Spinel.Gltf2Importer.getInstance(); const meshes = await glTF2Importer.import('../assets/gltf/BoxAnimated/glTF/BoxAnimated.gltf', context, material); const gl = context.gl; gl.clearColor(0.0, 0.0, 0.0, 1.0); gl.enable(gl.DEPTH_TEST); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); for (let mesh of meshes) { mesh.draw(); } } ...

ビルドして実行してみてください。glTFで読み込んだメッシュが、赤色になりましたね。

image.png

Gltf2Importerでマテリアルを読むようにする

さて、いよいよGltf2Importerでマテリアルを読めるようにしましょう。

型情報の修正

本質的な話でなく恐縮ですが、一部型定義の修正を行います。

glTFのマテリアル情報 には様々な情報がプロパティとして定義されており、それをTypeScriptとして型チェックできるようglTF2.tsというファイルを以前作成しましたね。しかし前回までのglTF2の型定義では一部おうちゃくして、型指定を曖昧に書いてしまっていた点がありました。修正してみましょう。

pbrMetallicRoughnessというプロパティの型指定を次のように修正してください。

// glTF2.ts ... export type Gltf2PbrMetallicRoughness = { baseColorFactor?: number[], baseColorTexture?: Gltf2TextureInfo, metallicFactor?: number, roughnessFactor?: number, metallicRoughnessTexture?: Gltf2TextureInfo, extensions?: Object, extras?: any } export type Gltf2Material = { pbrMetallicRoughness?: Gltf2PbrMetallicRoughness, // Object型だった型指定をGltf2PbrMetallicRoughness型に変更 normalTexture?: Gltf2NormalTextureInfo, occlusionTexture? : Gltf2OcclusionTextureInfo, emissiveTexture?: Gltf2TextureInfo, emissiveFactor?: number[], alphaMode?: string, alphaCutoff?: number, doubleSided?: boolean, name?: string, extensions?: Object, extras?: any } ...

これで後続のコーディングを問題なく進める準備ができました。

マテリアル読み込みのためのメソッド_loadMaterialの作成

以下の通り、_loadMaterialメソッドを追加します。この関数の中でMaterialクラスのインスタンスを作りますので、シェーダープログラムの文字列が必要です。これは、もうGltf2Importorクラスの中に、samples/main.tsにあったものを移動してしまいましょう。そして、importメソッドからmaterialインスタンスの引数を削除して、外部からはMaterialクラスのインスタンスを受け取らないようにします。

// Gltf2Importer.ts import Vector4 from './Vector4.js'; // 追加 export default class Gltf2Importer { private static __instance: Gltf2Importer; // 追加ここから private static readonly vertexShaderStr = ` precision highp float; attribute vec3 a_position; attribute vec4 a_color; varying vec4 v_color; void main(void) { gl_Position = vec4(a_position, 1.0); v_color = a_color; } `; private static readonly fragmentShaderStr = ` precision highp float; varying vec4 v_color; uniform vec4 u_baseColor; void main(void) { gl_FragColor = v_color * u_baseColor; } `; // 追加ここまで private constructor() {} async import(uri: string, context: Context) { // 変更 let response: Response; try { response = await fetch(uri); } catch (err) { console.log('glTF2 load error.', err); }; const arrayBuffer = await response!.arrayBuffer(); const gotText = this._arrayBufferToString(arrayBuffer); const json = JSON.parse(gotText) as Gltf2 const arrayBufferBin = await this._loadBin(json, uri); const meshes = this._loadMesh(arrayBufferBin, json, context); // 変更 return meshes; } // 追加ここから private _loadMaterial(json: Gltf2, materialIndex: number, context: Context) { const material = new Material(context, Gltf2Importer.vertexShaderStr, Gltf2Importer.fragmentShaderStr); if (materialIndex >= 0) { const materialJson = json.materials[materialIndex]; let baseColor = new Vector4(1, 1, 1, 1); if (materialJson.pbrMetallicRoughness != null) { if (materialJson.pbrMetallicRoughness.baseColorFactor != null) { const baseColorArray = materialJson.pbrMetallicRoughness.baseColorFactor; baseColor = new Vector4(baseColorArray[0], baseColorArray[1], baseColorArray[2], baseColorArray[3]); } } material.baseColor = baseColor; } return material; } // 追加ここまで private _loadMesh(arrayBufferBin: ArrayBuffer, json: Gltf2, context: Context) { // 変更 const meshes: Mesh[] = [] for (let mesh of json.meshes) { const primitive = mesh.primitives[0]; const attributes = primitive.attributes; let materialIndex = -1; // 追加 if (primitive.material != null) { // 追加 materialIndex = primitive.material; // 追加 } // 追加 const material = this._loadMaterial(json, materialIndex, context); // 追加 const positionTypedArray = this.getAttribute(json, attributes.POSITION, arrayBufferBin); let colorTypedArray: Float32Array; if (attributes.COLOR_0) { colorTypedArray = this.getAttribute(json, attributes.COLOR_0, arrayBufferBin); } const vertexData: VertexAttributeSet = { position: positionTypedArray, color: colorTypedArray! } const libMesh = new Mesh(material, context, vertexData); meshes.push(libMesh); } return meshes; }

これで外部からマテリアルを好きに指定できなくなってしまいましたが、glTFファイルの中のマテリアル情報によって、実際は様々な表現が可能です。

マテリアルの情報をプログラムに直書きすると汎用性が制限されてしまいます。こうしたものはモデルファイル内にあるそのモデルにあった情報を使う方がもちろん良いですね。

main.tsの変更

上記のクラス変更を受けて、アプリケーションコード側であるsamples/main.tsを以下のように変更します。

// samples/main.ts import Spinel from '../dist/index.js' async function main() { const canvas = document.getElementById('world') as HTMLCanvasElement; const context = new Spinel.Context(canvas); const glTF2Importer = Spinel.Gltf2Importer.getInstance(); const meshes = await glTF2Importer.import('../assets/gltf/BoxAnimated/glTF/BoxAnimated.gltf', context); const gl = context.gl; gl.clearColor(0.0, 0.0, 0.0, 1.0); gl.enable(gl.DEPTH_TEST); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); for (let mesh of meshes) { mesh.draw(); } } main();

実行

ビルドして実行してみましょう。サンプルのglTFファイルで指定されているマテリアル色が表示されましたね!

一部、紫色の三角形がでていますが、このサンプルモデルは、青色の箱の中に紫色の小さな箱が入っているという構造になっています。

奥行き値の兼ね合いで描画がたまたまこうなってしまったのかもしれませんね。いずれ、カメラクラスを導入したら、様々な角度から見れるようになりますので、そのときに全体像がわかるでしょう。

image.png

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

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

10日目:glTFで全てのプリミティブを表示しよう