クラス構文を使ってもっとライブラリっぽくしよう

クラス構文を使おう

今までは関数(function)だけでプログラムを構成していました。しかし、これではデータの保持の仕方など、制約が多くあります。
そこで、クラス構文の出番です。クラスは自身に変数を持てますから、ライブラリの設計をとても洗練されたものにできます。

さて、まずinitBuffer関数とinitProgram関数をクラス化することを考えましょう。

initBuffer関数ではVertexBufferObjectを作っていましたね。つまり頂点データです。これは、ポリゴンメッシュデータを作っていることになります。ですので、「Mesh」クラスとしてクラス化すると良さそうです。

// buffer.ts -> Mesh.ts へリネーム import Material from "./Material.js"; import Context from "./Context.js"; export default class Mesh { private _vertexBuffer: WebGLBuffer; private _vertexComponentNumber = 0; private _vertexNumber = 0; private _material: Material; private _context: Context; constructor(material: Material, context: Context, vertices:number[], vertexComponentNumber: number) { this._material = material; this._context = context; const gl = context.gl; const vertexBuffer = gl.createBuffer() as WebGLBuffer; gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STATIC_DRAW); this._vertexComponentNumber = vertexComponentNumber; this._vertexNumber = vertices.length / vertexComponentNumber; this._vertexBuffer = vertexBuffer; } draw() { const gl = this._context.gl; gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer!); gl.vertexAttribPointer( this.material.program!._attributePosition, this.vertexComponentNumber, gl.FLOAT, false, 0, 0); gl.drawArrays(gl.TRIANGLES, 0, this.vertexNumber); } get vertexBuffer() { return this._vertexBuffer; } get vertexComponentNumber() { return this._vertexComponentNumber; } get vertexNumber() { return this._vertexNumber; } get material() { return this._material; } }

ちなみに、それまでrender.tsにあったdrawSceneメソッドの中身もほぼdrawメソッドとしてMeshクラスに移しています。描画処理は、それ自体をRenderクラスなどのクラスとして独立させ、それに対してMeshインスタンスを都度与えて描画してもらう設計にすることもできますし、今回のようにMeshクラス自体に描画機能をもたせてしまう方法もあります。

さて、次はシェーダー周りのリファクタリングを見てみましょう。initProgram関数ではシェーダープログラムを作っていましたね。シェーダーはMeshの質感を決めるものですので、「Material」クラスとしてクラス化しましょう。
そして、3Dソフトを使ったことのある人は何となく分かるかと思いますが、マテリアルはメッシュに適用しますよね。ですので、MeshクラスはMaterialクラスのインスタンスを保持できることにしましょう。

// shader.ts -> Material.ts へリネーム import { ShaderType, WebGLProgram } from "./definitions.js"; import Context from "./Context.js"; export default class Material { private _program: WebGLProgram; 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); 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 } }

そして、initWebGL関数はWebGLコンテキストを作成するものですので、抽象化して「Context」クラスとしましょう。

// context.ts -> Context.ts へリネーム export default class Context { private _gl: WebGLRenderingContext; constructor(canvas: HTMLCanvasElement) { const gl = canvas.getContext('webgl') as WebGLRenderingContext; if (gl == null) { alert('Failed to initialize WebGL.'); } this._gl = gl; } get gl() { return this._gl; } }

この3つのクラスの関係はUMLのクラス図で表すとこのような形です(各メソッドの引数については長くなってしまうので省略しています)

image.png

さらにindex.tsの中身を大きく変えます。それまでのmain関数的な各関数呼び出しの処理を、ユーザーサイドに移してしまいます。こうすることで、それまでmain()関数の呼び出しという大雑把なライブラリの使い方しかできなかったのが、今度はユーザープログラム側でMeshやMaterialクラスのインスタンスをいくつでも作れたりと、より柔軟なライブラリの使い方ができるようになります。

以下、サンプル側のmain.jsの内容です。

// main.js import Spinel from '../dist/index.js' const vertexShaderStr = ` precision highp float; attribute vec3 a_position; void main(void) { gl_Position = vec4(a_position, 1.0); } `; const fragmentShaderStr = ` precision highp float; void main(void) { gl_FragColor = vec4(0.0, 0.0, 1.0, 1.0); } `; const vertices = [ 0.0, -1.0, 0.0, 1.0, 1.0, 0.0, -1.0, 1.0, 0.0 ] const vertexComponentNumber = 3; const canvas = document.getElementById('world'); const context = new Spinel.Context(canvas); const material = new Spinel.Material(context, vertexShaderStr, fragmentShaderStr); const mesh = new Spinel.Mesh(material, context, vertices, vertexComponentNumber); 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); mesh.draw();

index.tsの方は、全く別の処理を入れます。

// index.ts import Context from "./Context.js"; import Material from "./Material.js"; import Mesh from "./Mesh.js"; const Spinel = { Context, Material, Mesh }; export default Spinel;

これはなんでしょうか。Spinelというオブジェクトに、各クラスを格納してますね。こうすることで、先程のmain.jsのコードのように、import文をSpinelのインポート一つだけに減らした上で、Spine.Meshといった感じで各クラスにアクセスできるようになります。

クラスの変数に外から渡す値、コンストラクタで渡す? あとから渡す?

つまり、こういうことです。

クラスのプライベート変数に、オブジェクトをコンストラクタで渡す場合は以下のような感じです。

class Mesh { private _gl: WebGLRenderingContext; constructor(gl: WebGLRenderingContext) { this._gl = gl; } }

一方、あとから渡す場合は、以下のような感じです。

class Mesh { private _gl?: WebGLRenderingContext; constructor() { } setWebGLContext(gl: WebGLRenderingContext) { this._gl = gl; } }

この2通りのやり方。どちらがより望ましいのでしょうか? 実はこれ、思うより深い、悩ましい問題なのです。

後者の場合、明らかにわかるデメリットがあります。_glプライベート変数が未初期化の期間が存在してしまうということ。それも、setWebGLContextの呼び出しを忘れてしまった場合、ずっと未初期化の状態が続いてしまうということです。
ユーザープログラマが注意していればいいというだけの話でもありません。この「万が一未初期化じゃないだろうか?」ということを検査するためのチェックコードも書かないといけなくなってしまいます。これは、結構煩雑ですね。プログラム実装も少々汚くなります。

では、前者の方が常に望ましいのかと言うと…これは確かに、コンストラクタの時点ですぐに渡すわけですから、未初期化になってしまう可能性を最初から潰せます。しかし、これはこれでユーザープログラマに、Meshクラスのインスタンスを生成したい時にはすでにWebGLコンテキストを準備していないといけない、このことを必ず要求してしまうのです。これはこれで、ユーザープログラマにとっては、少しアプリケーション設計が面倒になる部分が出てくるかもしれませんね。もちろん、プログラム作法をある程度ユーザーに強制した方が良い方向へ行く場合もありますが。

さて、私の見解はというと・・・なかなか難しいですが、ライブラリはやはり堅牢さが重要です。前者のコンストラクタの段階で渡すことを強制して、未初期化の可能性を潰すことが基本は望ましいと思います。
よく考えてみると、そもそも必要とするものがまだ存在していない状況下で、それに依存するインスタンスが実体化すること自体が、設計として少し不健全だとはいえないでしょうか?
また、後者の場合いつでも設定できる、ということはAPIの呼び出すタイミングに自由が効きすぎるということでもあります。気をつけないと、ユーザーサイドからすると、各APIをそれぞれどういうタイミングで呼び出すのが正解かわかりにくい、という事態も招きかねません。一方、前者なら呼び出し順序が自ずと制約の中で決まってくる可能性が高いと言えます。

しかし、そういう前者の厳密な思想のもとで、ユーザープログラマが窮屈になりすぎてしまう場合は、例外的に後者を選ぶことも必要となるかもしれません。ここは、ケースバイケース、バランスだと思います。オブジェクトのセットだけでなく、付随した処理をやらせるなどする場合は、コンストラクタで負荷のかかることをやらせるのは禁物ですから、こうした点も考えどころです。

ライブラリ開発ではこんな細かいことについても、考えることが色々あるということですね。

今回のライブラリでは、堅牢性を重視して基本前者で行きたいと思います。コーディング作法を厳しく強制することになりますが、そうするとユーザープログラムがどういう感じ・作り方になるのか、という点も少し興味があります。まぁキツすぎたら、後で設計を一部後者に変えて、緩めればいいでしょう。

まとめ

それまでむき出しだったWebGL関数の呼び出しの多くを、オリジナルのクラスの中に封じ込めることができました。WebGLの詳細に立ち入ることなく、これらのクラスのメソッドを呼び出すだけで処理を作っていけますね。こうすることで、今後も高度かつ複雑な処理を、コードをきれいに保ちつつ開発することができます。

この時点のプロジェクトコードは、ここで公開されています 

4日目: ユーザー指定のシェーダーを使えるようにしよう >>

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