0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ブラウザで GLSL ライブエディタを書いた — WebGL の初期化 + コンパイルエラーを行番号付きで拾う + シェーダを URL hash で共有する話

0
Posted at

"シェーダを書きたい、でも環境構築は面倒、Shadertoy はちょっと重い、最低限で良い" 用に、ブラウザだけで動く GLSL fragment shader のライブエディタを書いた。左の textarea にコード → 220 ms debounce で自動再コンパイル → 右の <canvas> に反映。コンパイルエラーは 行番号付き で下に出る。書いたシェーダは URL hash に詰まるのでツイートで共有可能。WebGL ~50 行、純粋ロジック ~120 行、テスト 17 件。

shader-playground の画面: 左にフラグメントシェーダの GLSL コードエディタ (precision mediump float / uniform 宣言 / main()で gl_FragCoord から uv 計算 → radial wave)、右に WebGL canvas で実行結果。canvas にはマウスカーソル付近を中心とした暖色 (赤・橙・紫) の同心円状の波模様。上部に「🔗 URL コピー / ↺ デフォルトに戻す / ✓ コンパイル成功 / 60 fps」、エディタ上に uniforms チップ (vec2 u_resolution / vec2 u_mouse / float u_time)

🌐 デモ: https://sen.ltd/portfolio/shader-playground/
📦 GitHub: https://github.com/sen-ltd/shader-playground

全体構造

このページでやっていることは 4 つだけ:

  1. vertex shader は固定: full-screen quad を 2 トライアングルで描画
  2. fragment shader はユーザー編集: 毎フラグメントで main() が走り gl_FragColor を書く
  3. uniform を毎フレーム更新: u_resolution / u_mouse / u_time の 3 つ
  4. 編集 → 再コンパイル → 再描画 を 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: falsecanvas を「描画」として扱う設定。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 は getUniformLocationnull を返す。null に対して gl.uniform2f を呼ぶと "INVALID_OPERATION" になる (壊れないがログが汚れる)。

マウス座標は WebGL 流に「下原点」にする

MouseEvent.clientYcanvas 上端から下に増える (DOM 流) のに対し、gl_FragCoord.ycanvas 下端から上に増える (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: 行は単純な正規表現で構造化
  • getUniformLocationnull を必ずガードしてから 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 一覧 から。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?