概要
WebGL2 を含むプログラムを GitHub Actions の Node.js 環境で実行する現実的な選択肢は二つ
- Headless Chrome を使って localhost 越しに呼びたい式を渡し、評価された結果を受け取る
- node-gles を使って WebGL の関数呼び出しを OpenGL ES に内部で変換して呼び出す
どちらも面倒な点はありますが、それぞれ動かすところまで試したのでやり方を書いておきます。
対象読者
- Node.js を使ったことがある
- WebGL を使ったことがある
環境
- Node.js
- TypeScript
- Jest
リポジトリ
方法1. Headless Chrome を使う
Chrome には画面を立ち上げずにバックグラウンドで動作するヘッドレスモードがあり、プログラムからこれを操作することでテストやキャプチャの撮影などが自動で行えます。
Node.js から Chrome を操作するには次のいずれかのライブラリを使用するのが一般的なようです。
- Chrome を立ち上げるのに chrome-launcher を、操作を行うのに chrome-remote-interface を使う
- Headless Chrome 用の高機能 API を提供する Puppeteer を使う
今回はページを開いて式を実行するだけなのでどちらでも大して変わりませんが、より簡単な Puppeteer を使うことにします。
npm install -D puppeteer
実験1.1. WebGL の情報を取得して表示する(Chrome)
まずは WebGL のバージョンや使えるリソースのサイズを取得するプログラムで実験をしてみます。
ソースコード
次の webglSimple()
は引数に WebGL2RenderingContext
を取るのでこれをどうやって与えるかというのが今回の問題になります。
export function webglSimple(gl: WebGL2RenderingContext): string
{
return [
`------------------------------------------------------------`,
`gl.RENDERER | ${gl.getParameter(gl.RENDERER)}`,
`gl.VERSION | ${gl.getParameter(gl.VERSION)}`,
`------------------------------------------------------------`,
`gl.MAX_COMBINED_TEXTURE_IMAGE_UNITS | ${gl.getParameter(gl.MAX_COMBINED_TEXTURE_IMAGE_UNITS)}`,
`gl.MAX_CUBE_MAP_TEXTURE_SIZE | ${gl.getParameter(gl.MAX_CUBE_MAP_TEXTURE_SIZE)}`,
`gl.MAX_FRAGMENT_UNIFORM_VECTORS | ${gl.getParameter(gl.MAX_FRAGMENT_UNIFORM_VECTORS)}`,
`gl.MAX_TEXTURE_IMAGE_UNITS | ${gl.getParameter(gl.MAX_TEXTURE_IMAGE_UNITS)}`,
`gl.MAX_TEXTURE_SIZE | ${gl.getParameter(gl.MAX_TEXTURE_SIZE)}`,
`gl.MAX_VARYING_VECTORS | ${gl.getParameter(gl.MAX_VARYING_VECTORS)}`,
`gl.MAX_VERTEX_ATTRIBS | ${gl.getParameter(gl.MAX_VERTEX_ATTRIBS)}`,
`gl.MAX_VERTEX_TEXTURE_IMAGE_UNITS | ${gl.getParameter(gl.MAX_VERTEX_TEXTURE_IMAGE_UNITS)}`,
`gl.MAX_VERTEX_UNIFORM_VECTORS | ${gl.getParameter(gl.MAX_VERTEX_UNIFORM_VECTORS)}`,
`------------------------------------------------------------`,
].join('\n');
}
この関数を GitHub Actions から呼び出して結果を表示するのがとりあえずの目標です。
ブラウザから使うので HTML も仮で用意しておきます。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>webgl2-test</title>
</head>
<body>
<canvas id="gl"></canvas>
<script src="./index.js"></script>
</body>
</html>
テストコード
テストでは Puppeteer を使ってブラウザ上で JavaScript を実行します。
次の remoteEval()
関数は puppeteer.launch()
で Chrome を立ち上げた後 page.goto()
で localhost に接続します。そしてページが読み込まれたら page.evaluate()
を呼ぶことでページ上で式を実行してその結果を返します。
const puppeteer = require('puppeteer');
export async function remoteEval(expr: () => any, port: number, headless: boolean, chromeFlags: string[] = [])
{
return new Promise(async (resolve: (x:any) => void) =>
{
const browser = await puppeteer.launch({ headless: headless, args: chromeFlags });
const page = await browser.newPage();
await page.goto(`http://localhost:${port}`, { waitUntil: 'domcontentloaded' });
const result = await page.evaluate(expr);
await browser.close();
resolve(result);
});
}
これを使って src/index.ts
で定義した webglSimple()
を呼び出すだけのテストを書きます。
次の call_webglSimple()
は先ほどの HTML を開いたページ上で実行するため document.querySelector()
で canvas を取得することができます。これを通常モードとヘッドレスモードでそれぞれ実行するテストを書きます。
import {remoteEval} from "./chrome_helper";
const call_webglSimple = () => eval(`(() =>
{
const canvas = document.querySelector("canvas");
const gl = canvas.getContext("webgl2");
return webglSimple(gl);
})()`);
test("simple (chrome headless)", (async function()
{
return remoteEval(call_webglSimple, 8080, true).then(
(result:any) =>
{
console.log(result);
expect(`${result}`).not.toBe("");
});
}), 60000);
test("simple (chrome browser)", (async function()
{
return remoteEval(call_webglSimple, 8080, false).then(
(result:any) =>
{
console.log(result);
expect(`${result}`).not.toBe("");
});
}), 60000);
GitHub Actions
最後にこのプログラムを実際に実行するワークフローを書きます。run 以外はテンプレをそのまま使いました。ここでは tsc で TypeScript をコンパイルした後 http-server を立ち上げた状態で test を実行しています。
#... 略 ...
jobs:
chrome_simple:
runs-on: macos-latest
strategy:
matrix:
node-version: [12.x]
steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
- run: |
npm ci
npm install -D puppeteer
npm run tsc
npm run server ./public -p 8080 &
npm test -- chrome_simple
実行結果
simple (chrome headless)
------------------------------------------------------------
gl.RENDERER | WebKit WebGL
gl.VERSION | WebGL 2.0 (OpenGL ES 3.0 Chromium)
------------------------------------------------------------
gl.MAX_COMBINED_TEXTURE_IMAGE_UNITS | 32
gl.MAX_CUBE_MAP_TEXTURE_SIZE | 8192
gl.MAX_FRAGMENT_UNIFORM_VECTORS | 261
gl.MAX_TEXTURE_IMAGE_UNITS | 16
gl.MAX_TEXTURE_SIZE | 8192
gl.MAX_VARYING_VECTORS | 32
gl.MAX_VERTEX_ATTRIBS | 32
gl.MAX_VERTEX_TEXTURE_IMAGE_UNITS | 16
gl.MAX_VERTEX_UNIFORM_VECTORS | 256
------------------------------------------------------------
simple (chrome browser)
------------------------------------------------------------
gl.RENDERER | WebKit WebGL
gl.VERSION | WebGL 2.0 (OpenGL ES 3.0 Chromium)
------------------------------------------------------------
gl.MAX_COMBINED_TEXTURE_IMAGE_UNITS | 80
gl.MAX_CUBE_MAP_TEXTURE_SIZE | 16384
gl.MAX_FRAGMENT_UNIFORM_VECTORS | 1024
gl.MAX_TEXTURE_IMAGE_UNITS | 16
gl.MAX_TEXTURE_SIZE | 16384
gl.MAX_VARYING_VECTORS | 32
gl.MAX_VERTEX_ATTRIBS | 16
gl.MAX_VERTEX_TEXTURE_IMAGE_UNITS | 16
gl.MAX_VERTEX_UNIFORM_VECTORS | 1024
------------------------------------------------------------
GitHub Actions で動かした出力結果がこちらです。これで WebGL2 が動作することを無事確認できました!
ただし、ここで気になるのはヘッドレスモードのリソースサイズが通常モードより大きく制限されているという点です。このように同じ実行環境でもヘッドレスモードだとグラフィックスの挙動が変わるということは意識しておく必要があります。
実験1.2. Texture3D に MRT を使って描き込む(Chrome)
動作が確認できたので早速 WebGL2 でないと動かないプログラムを試してみましょう。
ソースコード
次の webglTexture3d()
は Texture3D に Multiple Render Targets を使って値を描き込み、その結果を gl.readPixels()
で読んで配列に詰めて返す関数です。描き込む値はフラグメントシェーダの中で定義しています。
export function webglTexture3d(gl: WebGL2RenderingContext): Uint8Array[]
{
const vs = gl.createShader(gl.VERTEX_SHADER) as WebGLShader;
{
gl.shaderSource(vs,
`#version 300 es
void main()
{
vec3[6] vertices = vec3[](
vec3(-1.0, -1.0, 0),
vec3(+1.0, -1.0, 0),
vec3(-1.0, +1.0, 0),
vec3(-1.0, +1.0, 0),
vec3(+1.0, -1.0, 0),
vec3(+1.0, +1.0, 0)
);
gl_Position = vec4(vertices[gl_VertexID], 1);
}`
);
gl.compileShader(vs);
}
const fs = gl.createShader(gl.FRAGMENT_SHADER) as WebGLShader;
{
gl.shaderSource(fs,
`#version 300 es
precision mediump float;
out uvec4 outColor[4];
void main()
{
uvec2 xy = uvec2(gl_FragCoord.xy);
outColor[0] = uvec4(xy + uvec2( 0, 0), 0u, 255u);
outColor[1] = uvec4(xy + uvec2(10, 10), 0u, 255u);
outColor[2] = uvec4(xy + uvec2(20, 20), 0u, 255u);
outColor[3] = uvec4(xy + uvec2(30, 30), 0u, 255u);
}`
);
gl.compileShader(fs);
}
const program = gl.createProgram() as WebGLProgram;
gl.attachShader(program, vs);
gl.attachShader(program, fs);
gl.linkProgram(program);
gl.useProgram(program);
const width = 2;
const height = 2;
const depth = 4;
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_3D, texture);
gl.texImage3D(gl.TEXTURE_3D, 0, gl.RGBA8UI, width, height, depth, 0, gl.RGBA_INTEGER, gl.UNSIGNED_BYTE, null);
const fb = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
for(let z = 0; z < 4; z++)
{
gl.framebufferTextureLayer(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0 + z, texture, 0, z);
}
gl.drawBuffers([gl.COLOR_ATTACHMENT0, gl.COLOR_ATTACHMENT1, gl.COLOR_ATTACHMENT2, gl.COLOR_ATTACHMENT3]);
gl.viewport(0, 0, width, height);
gl.drawArrays(gl.TRIANGLES, 0, 6);
let results = [];
const pixels = new Uint8Array(4*width*height);
for(let z = 0; z < 4; z++)
{
gl.readBuffer(gl.COLOR_ATTACHMENT0 + z);
gl.readPixels(0, 0, width, height, gl.RGBA_INTEGER, gl.UNSIGNED_BYTE, pixels);
results.push(pixels.slice());
}
return results;
}
テストコード
こちらも通常モードとヘッドレスモードでそれぞれ webglTexture3d()
を呼び出すテストを書きます。
import {remoteEval} from "./chrome_helper";
const call_webglTexture3d = () => eval(`(() =>
{
const canvas = document.querySelector("canvas");
const gl = canvas.getContext("webgl2");
return webglTexture3d(gl);
})()`);
test("texture3d (chrome headless)", (async function()
{
return remoteEval(call_webglTexture3d, 8080, true).then(
(result:any) =>
{
console.log(result);
expect(`${result}`).not.toBe([]);
});
}), 60000);
test("texture3d (chrome browser)", (async function()
{
return remoteEval(call_webglTexture3d, 8080, false).then(
(result:any) =>
{
console.log(result);
expect(`${result}`).not.toBe([]);
});
}), 60000);
GitHub Actions
ワークフローも 実験1.1 と同じで、呼び出す test だけ変えます。
jobs:
#... 略 ...
chrome_texture3d:
runs-on: macos-latest
strategy:
matrix:
node-version: [12.x]
steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
- run: |
npm ci
npm install -D puppeteer
npm run tsc
npm run server ./public -p 8080 &
npm test -- chrome_texture3d
実行結果
// texture3d (chrome headless)
[
{ '0': 1, '1': 0, '2': 0, '3': 0, '4': 0, '5': 0, '6': 0, '7': 0,
'8': 0, '9': 0, '10': 0, '11': 0, '12': 112, '13': 114, '14': 101, '15': 115 },
{ '0': 35, '1': 118, '2': 101, '3': 114, '4': 115, '5': 105, '6': 111, '7': 110,
'8': 32, '9': 51, '10': 48, '11': 48, '12': 32, '13': 101, '14': 115, '15': 10 },
{ '0': 112, '1': 114, '2': 101, '3': 99, '4': 105, '5': 115, '6': 105, '7': 111,
'8': 110, '9': 32, '10': 109, '11': 101, '12': 100, '13': 105, '14': 117, '15': 109 },
{ '0': 112, '1': 32, '2': 102, '3': 108, '4': 111, '5': 97, '6': 116, '7': 59,
'8': 10, '9': 111, '10': 117, '11': 116, '12': 32, '13': 117, '14': 118, '15': 101 }
]
// texture3d (chrome browser)
[
{ '0': 0, '1': 0, '2': 0, '3': 255, '4': 1, '5': 0, '6': 0, '7': 255,
'8': 0, '9': 1, '10': 0, '11': 255, '12': 1, '13': 1, '14': 0, '15': 255 },
{ '0': 10, '1': 10, '2': 0, '3': 255, '4': 11, '5': 10, '6': 0, '7': 255,
'8': 10, '9': 11, '10': 0, '11': 255, '12': 11, '13': 11, '14': 0, '15': 255 },
{ '0': 20, '1': 20, '2': 0, '3': 255, '4': 21, '5': 20, '6': 0, '7': 255,
'8': 20, '9': 21, '10': 0, '11': 255, '12': 21, '13': 21, '14': 0, '15': 255 },
{ '0': 30, '1': 30, '2': 0, '3': 255, '4': 31, '5': 30, '6': 0, '7': 255,
'8': 30, '9': 31, '10': 0, '11': 255, '12': 31, '13': 31, '14': 0, '15': 255 }
]
実行するとなんと二つの出力が全然異なる結果になってしまいました。これはヘッドレスモードの方が間違っており通常モードでは正しい結果を出力できています。ヘッドレスモードの実行結果はランダムっぽい値が入っているときと全て0埋めされているときがあるので、もはや何も描画できていないのかもしれません。
そしてもう一つ気になるのが、関数からは Uint8Array[]
型で結果を返しているのに受け取った結果は JSON に変換されているという点です。この理由はシンプルで、テストを行う Node.js と式を実行する Chrome はそれぞれ別々に JavaScript 実行環境を動かしており、両者の間でデータを受け渡す手段が文字列しかないためです。この変換は Puppeteer が内部で自動的に行っているようで、chrome-remote-interface だと容赦なく undefined が返って来るため事前に JSON.stringify()
などに通して文字列にした状態で返す必要があります。
自分の想定では複雑なシェーダについて CPU で同じ計算を行う関数を書いておき、シェーダの計算結果と照らし合わせるテストをしたいと思っていたので途中でこういった変換を挟まざるを得ないというのはちょっと微妙だなあという感想でした。まあテストまで全部 Chrome 上で行ってしまえばこれは問題になりませんが…
いずれにせよ WebGL の単体テストを行うという用途に対しては、この方法だと少し大がかり過ぎてあまり向いていない気がしました。しかしその分 Chrome での動作が保証できるという強みはあるので、厳密なデータ比較まで行わなくてもとりあえず動かすだけ動かしておくというのが良いのかもしれません。
方法2. node-gles を使う
node-gles は WebGL から OpenGL ES へのバインディングを提供するライブラリで、これ自身は内部で ANGLE を呼び出します。同じコンセプトで広く使われている headless-gl から WebGL2 に対応する気配がないため TensorFlow.js の中の人によって新たに開発されています。
しかしながら、2020年10月現在ではまだ WebGL2 の関数バインディングはほとんど入っていません。バインディングが少ない理由については一応説明があり、将来的に Khronos の IDL ファイルから自動生成する予定でとりあえず使うものしか入れていない(https://github.com/google/node-gles/issues/1#issuecomment-435165176) とのことです。ということで今はかなり作り途中のものを無理やり使っていくことになります。
ここで大事なのはバインディングさえ追加すれば WebGL 2.0 ( ES 3.0 ) の関数を呼び出せることで、追加するだけならそれほど難しくはないので現時点でも実用できなくはないということです。また、GitHub Actions の windows-latest と ubuntu-latest では残念ながら動きませんでしたが、macos-latest では動くことが確認できたのでひとまずこれで使ってみることにします。
npm install -D node-gles
実験2.1. WebGL の情報を取得して表示する(node-gles)
使うソースコードは 実験1.1 と同じ webglSimple()
関数なので省略します。ちなみに webglSimple()
は現在の node-gles が対応している範囲に収まっている(収めた)ためそのまま実行することができます。
テストコード
nodeGles.createWebGLRenderingContext()
で WebGL と同じ API にアクセスできるオブジェクトが返ってくるので、これをそのまま webglSimple()
に渡して結果を取得します。
const nodeGles = require('node-gles');
import {webglSimple} from "../src/index";
test("simple (node-gles)", () =>
{
const gl = nodeGles.createWebGLRenderingContext();
const result = webglSimple(gl);
console.log(result);
expect(result).not.toBe([]);
});
GitHub Actions
ワークフローは 実験1.1 とほぼ同じです。
jobs:
#... 略 ...
node_gles_simple:
runs-on: macos-latest
strategy:
matrix:
node-version: [12.x]
steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
- run: |
npm ci
npm install -D node-gles
npm test -- node_gles_simple
実行結果
simple (node-gles)
------------------------------------------------------------
gl.RENDERER | ANGLE (Apple Inc., Apple Software Renderer, OpenGL 4.1 core)
gl.VERSION | OpenGL ES 3.0 (ANGLE 2.1.0.9512a0ef062a)
------------------------------------------------------------
gl.MAX_COMBINED_TEXTURE_IMAGE_UNITS | 32
gl.MAX_CUBE_MAP_TEXTURE_SIZE | 16384
gl.MAX_FRAGMENT_UNIFORM_VECTORS | 1024
gl.MAX_TEXTURE_IMAGE_UNITS | 16
gl.MAX_TEXTURE_SIZE | 16384
gl.MAX_VARYING_VECTORS | 32
gl.MAX_VERTEX_ATTRIBS | 16
gl.MAX_VERTEX_TEXTURE_IMAGE_UNITS | 16
gl.MAX_VERTEX_UNIFORM_VECTORS | 1024
------------------------------------------------------------
node-gles でも無事に動作が確認できました。
実験1.1 で gl.RENDERER
の情報を取得したときは WebKit としか返してくれませんでしたが、今回は ANGLE が中で実際に呼んでいるグラフィックス API の情報を取得できていますね。Software Renderer と書いてあるのが若干気になりますが、まあこれは仮想環境なのでしょうがないのかもしれません。よくわかりませんが。
とりあえず動くことが確認できたので先に進むことにします。
実験2.2. Texture3D に MRT を使って描き込む(node-gles)
次に 実験1.2 で書いた webglTexture3d()
をこちらでも動かしてみます。
ここで問題になるのが webglTexture3d()
の中でバインディングが足りない関数がいくつかあることです。具体的には texImage3D()
, framebufferTextureLayer()
, drawBuffers()
, readBuffer()
とあと他にいくつかの定数が足りていないのでこれらを以下に追加していきます。
バインディングの追加
必要になるのは基本的に次の三つです
-
egl_context_wrapper.h(.cc)
に呼び出したい GLES の関数ポインタを取得してメンバに持っておく -
webgl_rendering_context.h(.cc)
に WebGL から受け取った引数を取り出して取得した関数ポインタに渡して呼び出す関数を定義する -
webgl_rendering_context.cc
に 2. で定義した関数にNAPI_DEFINE_METHOD()
で名前を付けて Node.js に公開する
あとはnpm install
を呼ぶと自動でコンパイルが走るようなので、これで build/Release/nodejs_gl_binding.node
が生成されて勝手に使えるようになります。
今回追加した差分はこちらから確認できます。
https://github.com/agehama/node-gles/compare/b4cb488...47d2d00
テストコード
実験2.1 と大体同じですがこちらは nodeGles
のパスだけ fork 版を呼ぶために変更しています。
const nodeGles = require('../temp/node-gles/src/index');
import {webglTexture3d} from "../src/index";
test("texture3d (node-gles)", () =>
{
const gl = nodeGles.createWebGLRenderingContext();
const result = webglTexture3d(gl);
console.log(result);
expect(result).not.toBe([]);
});
GitHub Actions
ワークフローにも fork 版の node-gles を使用するために、npm からではなく git から clone してインストールするという手順を加えています。
jobs:
#... 略 ...
node_gles_texture3d:
runs-on: macos-latest
strategy:
matrix:
node-version: [12.x]
steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
- run: |
cd temp
git clone https://github.com/agehama/node-gles.git
cd node-gles
npm install
cd ../../
npm ci
npm test -- node_gles_texture3d
実行結果
texture3d (node-gles)
[
Uint8Array(16) [
0, 0, 0, 255, 1, 0, 0, 255,
0, 1, 0, 255, 1, 1, 0, 255
],
Uint8Array(16) [
10, 10, 0, 255, 11, 10, 0, 255,
10, 11, 0, 255, 11, 11, 0, 255
],
Uint8Array(16) [
20, 20, 0, 255, 21, 20, 0, 255,
20, 21, 0, 255, 21, 21, 0, 255
],
Uint8Array(16) [
30, 30, 0, 255, 31, 30, 0, 255,
30, 31, 0, 255, 31, 31, 0, 255
]
]
こちらが実行結果です。今度は完全に期待通りの出力が得られました!!嬉しい!
ということでバインディングを追加するという手間さえ無ければ文句なくお勧めできるのですが、現状だと実験的なプロジェクトとかでない限りまだ気軽には使いにくそうだなと感じました。
また、ブラウザの WebGL との挙動の違いについてですが、node-gles だと例えばシェーダが使っていない uniform 変数へ値をセットしようとするとセグフォで落ちてしまいます。ブラウザだと仮に不正な入力があってもそのまま投げて落ちないよう入念にチェックしていると思うのでそこは大きく異なる点だと思います。グラフィックス部分に限れば ANGLE に投げているだけなのでブラウザと描画結果が変わるみたいなことはあまり起きにくいんじゃないかなあと思います。
結論
しばらくは node-gles の成長を待ちつつ使いたい関数を自分で追加していくのが良いかなと思いました。それはそれとして Puppeteer でページを表示するだけなら簡単だったので headless: false
でとりあえず動かしてキャプチャでも撮っておくのが良さそうでした。