‹ back

tech reading time / 8 min

自前でWebGL描画パイプラインを作っているよ

最近シェーダーを書くのにハマっていて、自前で書くための環境が欲しくなったのでブラウザベースで作っている。

atolix/repaint atolix/repaint

中身としてはVite + TypeScriptでGLSLを動かすための小さい描画基盤で、基本的なfragment shaderの描画とそれに対してpost effectを追加できるような仕組みになっている。

全体の流れ

入口はsrc/main.ts。 canvasを取得して、main.fragを読み込み、Rendererを作ってrequestAnimationFrameで毎フレーム描画している。

const canvas = document.querySelector("#canvas") as HTMLCanvasElement;
const sceneSource = resolveIncludes(mainSource, "./shaders/main.frag");
const renderer = new Renderer(canvas, sceneSource, createPostProcessPasses());

function frame(time: number) {
  renderer.render(time);
  requestAnimationFrame(frame);
}

requestAnimationFrame(frame);

処理フローはだいたいこう。

  1. main.fragを読み込む
  2. GLSL内の#includeを解決する
  3. ScenePassでシーンをframebufferに描画する
  4. 描画結果のtextureをPostProcessPipelineに渡す
  5. 有効なpost process passを順番に通す
  6. 最後にcanvasへ出力する

WebGLでよくある一度オフスクリーンに描いて、その結果を次のシェーダーで加工する構成。 最初からpost process前提にしているので、あとからinvert、noise、blur、bloomみたいな処理を足しやすい。

ScenePass

ScenePassはメインシーンを描く役割を持つ。 内部では専用のFramebufferを持っていて、毎フレームそこへ描画する。

const sceneTexture = this.scenePass.render({
  resolution,
  time: t,
  debugMode,
});

ここで返ってくるのはcanvasに直接描かれた結果ではなく、framebufferに紐づいたtexture。 このtextureをpost processの入力として使う。

main.fragは今のところかなりシンプルで、uvとtimeから色を作っている。 dキーを押すとdebug modeが切り替わり、UV、距離、グリッド表示に変えられる。

vec2 uv = getUV();
float d = length(uv);
vec3 color = vec3(uv, 0.5 + 0.5 * sin(u_time));
if (u_debugMode == 1) color = debugUV(uv);
if (u_debugMode == 2) color = debugDistance(d);
if (u_debugMode == 3) color = debugGrid(uv);
outColor = vec4(color, 1.0);

PostProcessPipeline

post process側はPostProcessPipelineが担当している。 createPostProcessPasses()でpass一覧を作り、それぞれのfragment shaderをprogramにして保持する。

export function createPostProcessPasses(): PostProcessPassConfig[] {
  return [
    createPostProcessPass("invert", invertSource, "./shaders/effects/invert.frag", false),
    createPostProcessPass("noise", noiseSource, "./shaders/effects/noise.frag", false),
  ];
}

enabledなpassがない場合は、scene textureをoutput.fragでそのままcanvasへ出す。 enabledなpassがある場合は、scene textureを入力にして順番に加工していく。 途中結果は2枚のframebufferを交互に使って受け渡す。

let currentTexture = inputTexture;

for (let i = 0; i < enabledPasses.length; i++) {
  const pass = enabledPasses[i];
  const isLast = i === enabledPasses.length - 1;
  const outputFramebuffer = isLast ? null : this.framebuffers[i % 2];

  drawPass(this.gl, {
    program: pass.program,
    framebuffer: outputFramebuffer,
    textures: [
      {
        name: "u_scene",
        texture: currentTexture,
      },
    ],
  });

  if (outputFramebuffer) currentTexture = outputFramebuffer.texture;
}

最後のpassだけはframebuffer: nullにしてcanvasへ直接描く(TDのnullと同じノリかもしれない) それ以外はframebufferへ描いて、次のpassのu_sceneに渡すフローになっている。

drawPassの役割

drawPassは各passに共通するWebGLの手続きをまとめている。 framebufferをbindして、programをuseして、uniformとtextureを設定して、fullscreen triangleを描く。

gl.useProgram(program);
gl.drawArrays(gl.TRIANGLES, 0, 3);

毎回四角形を作らず、3頂点のfullscreen triangleで画面全体を覆う形。 post processでは頂点自体に意味がほぼないので、fragment shaderを画面全体に走らせるための最小構成として扱える。

uniformは今のところ1f1i2fだけ。 texture uniformも配列で渡せるようになっている。

includeとHMR

GLSLにはブラウザ標準のincludeがないので、resolveIncludesで自前解決している。

#include "./lib/uniforms.glsl"
#include "./lib/util.glsl"
#include "./lib/noise.glsl"
#include "./lib/debug.glsl"

import.meta.globsrc/shaders/**/*.glslを読み込み、#include "..."を再帰的に置換する。 循環includeも検出しているので、同じファイルをぐるぐる参照した時はエラーになる。

さらにViteのHMRにも対応している。 main.fragを書き換えた時にprogramを作り直し、compileに成功したら差し替える。 失敗した場合はエラーを返すので、前のprogramを壊さずに済む。

シェーダーを書いているとcompile errorは頻繁に出るので画面をいちいち落とさないために安全に倒している。

まだリソースの管理とかfeedbackの仕組みが全然入っていないので今後追加していきたい。