"シェーダを書きたい、でも環境構築は面倒、Shadertoy はちょっと重い、最低限で良い" 用に、ブラウザだけで動く GLSL fragment shader のライブエディタを書いた。左の textarea にコード → 220 ms debounce で自動再コンパイル → 右の
<canvas>に反映。コンパイルエラーは 行番号付き で下に出る。書いたシェーダは URL hash に詰まるのでツイートで共有可能。WebGL ~50 行、純粋ロジック ~120 行、テスト 17 件。
🌐 デモ: https://sen.ltd/portfolio/shader-playground/
📦 GitHub: https://github.com/sen-ltd/shader-playground
全体構造
このページでやっていることは 4 つだけ:
- vertex shader は固定: full-screen quad を 2 トライアングルで描画
-
fragment shader はユーザー編集: 毎フラグメントで
main()が走りgl_FragColorを書く -
uniform を毎フレーム更新:
u_resolution/u_mouse/u_timeの 3 つ - 編集 → 再コンパイル → 再描画 を 220 ms debounce で回す
それぞれの嫌な落とし穴を順に潰す形で実装してある。
WebGL コンテキスト初期化の防御コード
function initGL() {
const gl =
els.canvas.getContext("webgl", { antialias: true, premultipliedAlpha: false }) ||
els.canvas.getContext("experimental-webgl");
if (!gl) {
setStatus("WebGL が利用できません", "bad");
return null;
}
state.gl = gl;
return gl;
}
ポイント:
-
experimental-webglフォールバックは IE11 や古い Android Chrome で稀に必要だった。今は外しても 99% 動くが、書き忘れた状態で動かない端末に当たったときの調査時間がもったいないので 1 行で書く -
premultipliedAlpha: falseは canvas を「描画」として扱う設定。trueだと CSS の background がブレンドされてしまい、シェーダ側でalpha=1を出力しても下が透けて見える、というデバッグしにくい現象が起きる -
antialias: trueはデフォルトだが明示。MSAA が走るので、edge が綺麗
コンパイルエラーを行番号付きで拾う
ブラウザの getShaderInfoLog() が返す文字列は (環境依存があるが) おおむね以下のフォーマット:
ERROR: 0:5: 'foo' : undeclared identifier
ERROR: 0:12: 'bar' : assignment to const variable
WARNING: 0:3: implicit conversion
-
ERROR/WARNINGの区別 -
0は "ファイル番号" (1 ファイル shader なら常に 0、ignore) -
5が ソース行番号 - 残りがメッセージ
正規表現 1 本でパースする:
const ERROR_RE =
/^\s*(ERROR|WARNING)\s*:\s*(\d+)\s*:\s*(\d+)\s*:\s*(.+?)\s*$/i;
export function parseShaderError(infoLog) {
if (!infoLog) return [];
const out = [];
for (const rawLine of infoLog.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line) continue;
const m = line.match(ERROR_RE);
if (m) {
out.push({
severity: m[1].toUpperCase() === "ERROR" ? "error" : "warning",
line: Number(m[3]),
message: m[4],
});
} else {
// 一部ドライバは free-form の summary 行を最初に出す。line 0 で表示。
out.push({ severity: "error", line: 0, message: line });
}
}
return out;
}
ハマりやすいのは:
-
(ERROR|WARNING)を case-insensitive で書く (iフラグ): ドライバによってErrorだったりERRORだったりする -
マッチ失敗時に rawLine を捨てない: Mali / Adreno など一部 GPU では
Fragment shader failed to compile with the following errors:のような summary 行を最初に出す。{line: 0}で UI に出して、ユーザーに「何か変なことが起きた」と分かるようにする
ユニットテストで両方を pinned:
test("parseShaderError surfaces free-form lines as line-0 errors", () => {
const log =
"Fragment shader failed to compile with the following errors:\n" +
"ERROR: 0:5: 'foo' : undeclared identifier\n";
const out = parseShaderError(log);
assert.equal(out.length, 2);
assert.equal(out[0].line, 0); // free-form summary
assert.equal(out[1].line, 5); // structured row
});
uniform 自動バインド
ユーザーが書く shader には uniform vec2 u_resolution; のような宣言が並ぶ。これらを JS 側で getUniformLocation → 毎フレーム gl.uniform2f で更新する。
リンク後に loc を取って覚えておく:
state.uniformLocs = {
u_resolution: gl.getUniformLocation(program, "u_resolution"),
u_mouse: gl.getUniformLocation(program, "u_mouse"),
u_time: gl.getUniformLocation(program, "u_time"),
};
毎フレーム:
const { u_resolution, u_mouse, u_time } = state.uniformLocs;
if (u_resolution) gl.uniform2f(u_resolution, gl.drawingBufferWidth, gl.drawingBufferHeight);
if (u_mouse) gl.uniform2f(u_mouse, state.mouse[0] * gl.drawingBufferWidth, state.mouse[1] * gl.drawingBufferHeight);
if (u_time) gl.uniform1f(u_time, (performance.now() - state.startTime) / 1000);
if (u_resolution) の null チェックが大事。ユーザーが宣言を消した uniform は getUniformLocation が null を返す。null に対して gl.uniform2f を呼ぶと "INVALID_OPERATION" になる (壊れないがログが汚れる)。
マウス座標は WebGL 流に「下原点」にする
MouseEvent.clientY は canvas 上端から下に増える (DOM 流) のに対し、gl_FragCoord.y は canvas 下端から上に増える (WebGL/OpenGL 流)。これを混ぜると u_mouse が上下反転する:
function onMouseMove(e) {
const rect = els.canvas.getBoundingClientRect();
state.mouse[0] = (e.clientX - rect.left) / rect.width;
state.mouse[1] = 1 - (e.clientY - rect.top) / rect.height; // ← flip
}
これで vec2 m = u_mouse / u_resolution.xy * 2.0 - 1.0; のような典型コードがそのまま動く。
URL hash でシェーダを共有
shader source を location.hash に詰めると 同じシェーダの URL が短く なる。base64url 化だけの単純実装:
export function encodeShaderToHash(source) {
const bytes = new TextEncoder().encode(source);
let bin = "";
for (let i = 0; i < bytes.length; i++) {
bin += String.fromCharCode(bytes[i]);
}
return btoa(bin)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
export function decodeShaderFromHash(hash) {
if (!hash) return "";
let b64 = String(hash).replace(/-/g, "+").replace(/_/g, "/");
while (b64.length % 4) b64 += "=";
let bin;
try { bin = atob(b64); } catch { return null; }
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
try {
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch { return null; }
}
UTF-8 round-trip するので日本語コメントが入っていても壊れない:
test("encode/decode round-trips UTF-8 (comments in Japanese)", () => {
const src = "// シェーダのコメント\nvoid main() { gl_FragColor = vec4(1.0); }";
const hash = encodeShaderToHash(src);
assert.equal(decodeShaderFromHash(hash), src);
});
「圧縮しないと長すぎでは?」と聞かれそうだが、本ツールが想定する 30-50 行のシェーダだと 1-2 KB、base64 化して 1.4-2.7 KB、URL に十分入る (Chrome / Firefox の URL 上限は 2 MB 前後)。大きい場合だけ CompressionStream (gzip in browser) を被せる という拡張は容易だが、API が async になるので今回は省略。
編集中の "本当に変わった?" 判定
textarea の input イベントは矢印キーや行の末尾だけの編集でも発火する。毎回 recompile すると GPU が頻繁にスワップされる:
export function shouldRecompile(prev, next) {
if (prev === next) return false;
const np = String(prev || "").replace(/\r\n/g, "\n").trimEnd();
const nn = String(next || "").replace(/\r\n/g, "\n").trimEnd();
return np !== nn;
}
- CRLF / LF を fold: エディタによっては保存時に CRLF を吐く。LF 統一の textarea にコピペすると不必要に compile が走る
-
末尾空白を trim: タイプ途中の
\n(空行追加) だけで recompile する必要なし -
本物の差分が来たら true → debounce 220 ms 後に
buildProgram(next)が走る
220 ms は感覚的に「タイプを止めた直後に反映」がちょうど良い値。50 ms だと連打で fast-recompile が走り過ぎる、500 ms だと反応が遅く感じる。
まとめ
WebGL 1 + textarea + 17 テストで「ブラウザだけの shader 環境」が ~350 行になる。鍵は:
- WebGL コンテキスト取得時の
premultipliedAlpha: falseを覚えとく -
getShaderInfoLog()のERROR: 0:N:行は単純な正規表現で構造化 -
getUniformLocationのnullを必ずガードしてからuniform*fを叩く - マウス座標は WebGL 座標系に flip
- URL hash で共有するなら UTF-8 round-trip 可能な base64url、
CompressionStreamは必要になってから - 編集 → 再コンパイルは末尾空白 / CRLF を folding して debounce、200 ms 前後がスイートスポット
ソース: https://github.com/sen-ltd/shader-playground — MIT、合計 ~350 行 (JS)、17 ユニットテスト、ビルド不要、依存ゼロ。
🛠 本記事は SEN 合同会社 が公開している小さな開発者ツール群の 1 つ。他は portfolio 一覧 から。
