一覧に戻る

luma.glでWebGLに入門する

#JavaScript#WebGL#TypeScript#deckgl#luma.gl

この記事は、MIERUNE Advent Calendar 2021の5日目の記事です。

はじめに

       ____ 
     /      \ 
   /  _ノ  ヽ、_  \ 
  / o゚((●)) ((●))゚o \  シェーダーやりたいお…
 |     (__人__)    | 
  \     ` ⌒´     / 

       ____ 
     /      \ 
   /  _ノ  ヽ、_  \ 
  /  o゚⌒   ⌒゚o   \  でも生のWebGL操作するのつらいお…
 |     (__人__)    |   
  \     ` ⌒´     / 

       ____ 
     /⌒  ⌒  \ 
   /( ●)  (● ) \ 
  /::::⌒(__人__)⌒:::\   だからluma.glでやるお!
 |     |r┬-|     | 
  \     `ー'´     /

本記事のゴール

luma.glで簡単なアニメーションを作成します。

https://kanahiro.github.io/lumagl-example/

WebGLについて

WebGLはブラウザからGPUにアクセスするためのJavaScript向けAPIです。GPUへの実際の命令はGLSLという言語で記述します。シェーダーと呼ばれたりします。シェーダーはJavaScript上では単なる文字列で、実行時にコンパイルされます。

このWebGL、なかなか低レベルなAPIとなっていて、ちょっと図形を描画するだけでもかなりのコード量になります。イメージ的には、DOMを生JSで操作する感じで結構つらい。WebGLももうちょっと高レベルなAPIでラップして欲しい訳です。

luma.glを使おうと思った理由

WebGL、というかブラウザで3Dとなると、デファクトスタンダードのライブラリとしてThree.jsがあります。普通はこれを使っておくのがよいでしょう。今回はWebGL自体の学習が目的なので、もう少しWebGLに近い低レベルなAPIがいいなと思い、そこで採用したのがluma.glです。

またもうひとつ、Three.jsはWebGL2で使えるようになるTransform Feedbackに未対応らしいことも他の選択肢をさがす動機でした。luma.glは対応しています。

https://luma.gl/

luma.glの特徴

あのdeck.glの裏側で動いているライブラリです。ドキュメントによれば、luma.glのAPIでも低レベル・中レベル・高レベルに分かれているようです。主に中・高レベルのAPIを操作することになるでしょう。モジュールごとに程度の差はありますが、まさにWebGLのAPIをラップするものです。WebGLほどめんどうくさくなく、でも同じような文脈でAPIを使う感じです。

ちなみにTypeScript対応は未完了です(2021-11)。

luma.glを使ってみる

インストール

中レベルAPIの@luma.gl/webglと、高レベルAPIの@luma.gl/engineを使います。

npm install @luma.gl/webgl @luma.gl/engine

アニメーションループ

WebGLの描画の土台、いわゆるアニメーションループは、@luma.gl/engnineが便利なクラスAnimationLoopを提供しています。

import { AnimationLoop } from '@luma.gl/engine';

const loop = new AnimationLoop({
    // @ts-ignore
    onInitialize({gl}) {
        // ...頂点やシェーダーの初期化
        return { hoge, fuga };
    },
    // @ts-ignore
    onRender({gl, hoge, fuga...}) {
        // ...フレームごとの処理
    },
});

loop.start(); // 描画開始

クラス名、関数名から、使い方は明快です。 onInitialize()が返す値をonRender()の引数として受け取れるのがポイントです。 この状態では(そもそもサンプルコードとして不完全ですが)、頂点情報を全く定義していないので、画面には何も表示されません。頂点情報(Model)の定義、アニメーション描画定義を行う必要があります。

Model定義

luma.glでは、頂点やシェーダーをひとまとめにするModelクラスが定義されています。 とりあえず三角形を描画するための頂点とシェーダーを定義してみます。 AnimationLoopでは、Modelは初期化時onInitialize()で定義します(return { model}とし、onRender()に渡します)。


import { AnimationLoop, Model } from '@luma.gl/engine';
import { Buffer } from '@luma.gl/webgl';

// 以上略
        onInitialize: function ({ gl }) {
            const positions = [0.0, 0.6, 0.6, -0.6, -0.6, -0.6]; // 頂点の定義:[p1x, p1y, p2x, p2y, p3x, p3y]
            const positionBuffer = new Buffer(gl, new Float32Array(positions));
            const model = new Model(gl, {
                // Vertex Shader
                vs: `
                attribute vec2 position;
                varying vec2 fPosition;
    
                void main() {
                    fPosition = position;
                    gl_Position = vec4(position, 0.0, 1.0);
                }
                `,
                // Fragment Shader
                fs: `
                varying vec2 fPosition;
                void main() {
                    gl_FragColor = vec4(fPosition, length(fPosition), 1.0);
                }
                `,
                attributes: {
                    position: positionBuffer,
                },
                vertexCount: positions.length / 2, // 頂点の数
            });
            return { model }; // onRender()へ渡す
        },
// 以下略

Modelをアニメーションループで描画

描画はonRender()で定義します。 画面をお掃除 -> 頂点バッファを操作 -> 描画 を毎フレーム行うことで、アニメーションが実現します。


import { AnimationLoop, Model } from '@luma.gl/engine';
import { Buffer, clear } from '@luma.gl/webgl';

// 以上略
    onRender({ gl, model }) {
        clear(gl, { color: [0, 0, 0, 1] }); // 画面を黒一色で初期化
        model.draw(); // onInitialize()で定義したModelを描画
    },
// 以下略

この時点で、画面に三角形が表示されるようになります。

しかしアニメーションループとか、毎フレーム描画とか言いながら、微動だにしない三角形だけが表示されても面白くありません。

アニメーションさせてみる

頂点を操作するには、時間の推移を用いる方法や、前フレームの頂点から次フレームの頂点を計算する方法があります(きっとこれ以外にもありますが、私がやったことあるのがこれら)。後者はこの記事で紹介するにはヘヴィなので、前者の方法で三角形を動かしてみます。

フレームごとに、Modelの時間情報を更新する

結論のコードだけ。

    // @ts-ignore
    onRender({ gl, model }) {
        clear(gl, { color: [0, 0, 0, 1] });

        const time = performance.now(); // JavaScriptで時間情報を取得
        model.setUniforms({ time });  // modelに時間情報を付与
        
        model.draw();
    },

これでModelにフレーム単位で新たな時間情報が与えられます。 次に、Model側でそれを受け取る準備をします。

時間情報を用いた描画

時間ごとに頂点を動かしてみましょう。頂点の移動にはいろいろありますが、今回は回転させてみます。WebGLの世界では、頂点はベクトルであり、ベクトルの変換に行列を使用できます。今回は回転行列なるものを利用して、三角形を回してみます。

// 以上略
        const model = new Model(gl, {
            vs: `
            uniform float time; // setUniforms()から値を受け取る

            attribute vec2 position;
            varying vec2 fPosition;

            // 回転行列: Z軸に対してラジアンでいうrだけ回転
            mat2 rot(float r) {
                float cr = cos(r);
                float sr = sin(r);
                return mat2(
                    cr, sr,
                    -sr, cr
                );
            }
            void main() {
                fPosition = position;
                gl_Position = vec4(rot(time * 0.001) * position, 0.0, 1.0); // 回転行列を用いてpositionベクトルを座標変換
            }
            `,
// 以下略

timeの値のまま回転させると余りにも回転速度が速いので、ちょうど良いくらいに値を小さくしています。以上のコードで、三角形が回転します。

上記のサンプルコードはすべてこちらに置いてあります

https://github.com/Kanahiro/lumagl-example

終わりに

私みたいな、シェーダーやってみたいけどWebGLは記述量が多くてつらい、という方には本記事の内容が刺さるかもしれません。

WebGLと聞くとThree.jsを使いがちですし、単にブラウザで3Dモデルを表示するなら、最も手早い選択肢でしょう。ただ、シェーダー自体を学びたいとか、WebGLに近いレイヤーを触りたい場合には、luma.glはおすすめできる選択肢だと思います。