公開

シェーダーの課題を readPixels で採点し、解けることをテストで確かめる

GLSL Atelier を 31 レッスンから 60 レッスンに増やした。描いた画素を 24×24 の点で採点し、シェーダーを JS に移して解答が通ることを Node で確かめ、ブラウザでは 4 通りの画面で全レッスンを回した。そこで見つかった初回実行のバグも書く。

GLSL Atelier は、GLSL のシェーダーと Three.js のシーンを書いて学ぶアプリです。2026-10-06 の 2 本の PR で、14 トラック 31 レッスンから 21 トラック 60 レッスンにしました(#27・#33)。採点は、書いたコードが実際に描いたもので行います。シェーダーはページで直接描画して gl.readPixels で画素を読み、Three.js は不透明オリジンのサンドボックスで動かします(学習アプリ 3 本の記事)。

描いた画素を格子の点で調べる

シェーダーは 128px 四方・固定の時刻で描き、24×24 の格子の点で画素を読みます。課題ごとに、どの点が何色であるべきかを書きます。たとえば放射する光の課題では 0°・30°・60°・90° の方向の画素を見るので、atan の引数を入れ替えた書き方も見分けられます。

新しい課題のために判定も足しました。乱数の課題は「8×8 の各セルが 1 色で、セルどうしは違う色」(cellsFlat)、ノイズの課題は「隣り合う点の差が 0.35 を超えない」(smooth)です。セルラーノイズでは差の上限を 0.3 にしました。最も近い点までの距離は 1-リプシッツなので、周りの 3×3 のセルを探す正しい実装なら、GPU のハッシュが何を返しても隣り合う点の差は 0.24 を超えません。自分のセルしか見ない初期コードは、セルの境目で約 0.9 跳ねます。

解答が通り、初期コードが落ちることを Node で確かめる

採点が甘いと、何も書かずに通る課題ができます。実際、#27 で既存のレッスンを見直すと、動くしまの課題は初期コードのままで合格していました。共通のヘッダーが uniform float u_time を宣言しているので、「ソースに u_time がある」という判定は何も書かなくても満たされていたためです。いまは main の中で u_time と sin( を使うことを求めています。

これを全レッスンで防ぐのが solvable.test.ts です。シェーダーの課題は JS に移植し、採点と同じ 24×24 の位置(128px、t = 1)で評価して、解答は通り初期コードは落ちることを確かめます。Three.js の課題は、Node で実際の three を使って解答と初期コードを実行し、シーングラフの判定にかけます。そのために、ランナーのシーングラフの走査を、ブラウザに依存しないモジュールに切り出しました。

光のないシーンは Raycaster で描く

Three.js の課題にも画素の判定がありますが、Node には WebGL がありません。#33 では、光を使わないシーン向けの簡単な描画を作りました。ランナーが調べる画素ごとに、three 自身の Raycaster とカメラの行列で光線を飛ばし、当たった物の色を塗ります。扱うのは基本色、光源なしの emissive、DataTexture の画素(needsUpdate までは黒)、点群の四角(gl_PointSize と同じ大きさ)、アルファの合成です。

これで光を使わない 10 レッスンの画素判定を、Node で 1:1 と 16:9 の両方のプレビューで確かめられるようになりました。どのレッスンをこのモデルで描けるかはテストで固定し、光・霧・影・自作シェーダーのシーンはブラウザだけで判定します。ブラウザで調べたすべての点で、このモデルの予測は実際に読み出した画素と一致しました。

ブラウザでは 4 通りの画面で全レッスンを回す

最後は本物のブラウザです。本番ビルドを本番と同じ _headers で配り(アプリは厳格な CSP、/sandbox.html は緩和した CSP)、ヘッドレス Chrome 154 の SwiftShader WebGL で 60 レッスンすべてを実際の UI で操作します。開く、初期コードでチェック、解答を見る、チェック、リセット、の順です。

画面 プレビュー 解答 初期コード
1280×900(1x) 1:1 60/60 合格 60/60 不合格
1440×900(2x) 1:1 60/60 合格 60/60 不合格
900×800(1x) 16:9 60/60 合格 60/60 不合格
390×844(2x) 16:9 60/60 合格 60/60 不合格

サンドボックスのタイムアウトとコンソールエラーは、4 通りとも 0 でした。初期コードは自分の課題の判定で落ちたときだけ不合格と数え、タイムアウトで落ちたものは印を付けて区別します。ほかに、updateProjectionMatrix() を忘れた fov の変更や、needsUpdate のないテクスチャのような惜しい解答をサンドボックスに直接送り、どれも不合格になることを確かめました。

回して見つかった、最初の実行が消えるバグ

この巡回で、Three.js のすべてのレッスンに関わるバグが見つかりました。レッスンを開くたびにビューを作り直すので、サンドボックスの iframe も新しいウィンドウになり、中のランナーはまだ読み込み中です(ヘッドレス Chrome で約 0.4〜0.5 秒)。ところが親の制御側は、古いウィンドウの「準備完了」を信じたままでした。そのため、開いた直後の最初の実行(入力を待って走るライブプレビューや、すぐに押したチェック)は読み込み中のウィンドウに送られて消え、5 秒後に「実行がタイムアウトしました(無限ループの可能性)」と出ていました。

ブラウザで測ると、開いて 50ms 後のチェックは 5.08 秒かかり、タイムアウトで落ちていました。修正後は 0.53〜0.56 秒で、課題そのものの判定で落ちます。「準備完了」を、それを送ってきたウィンドウに結びつけ、作り直されたウィンドウに送った実行は新しいタイムアウトで送り直すようにしました。jsdom のテストを 3 本足し、うち 2 本は修正を戻すと落ちることを確かめています。

参考