第1部 WebGL2 の基本 — 第4章

アニメーションと uniform

第3章の三角形は、一度描いたきり止まっていました。頂点データもシェーダーも描画の瞬間に固定で、 外から値を差し込む口がなかったからです。この章ではuniformという 2 つ目の入力経路を追加し、時間とマウス座標を毎フレーム流し込んで、三角形を動かします。 あわせて、第2章から持ち越していた宿題 — 毎フレーム描画するループと、リサイズ処理のループへの統合 — を片付けます。

three.js で言えば、ShaderMaterialuniformsTHREE.Clockの経過時間を毎フレーム書き込み、renderer.setAnimationLoopで回していた、あの仕組みの中身です。

第3章と同じ 3 頂点の三角形ですが、u_timeでゆっくり回転・脈動し、canvas 上でポインタを動かすとu_mouseに反応してポインタの方向へ寄ります。頂点データは 1 度転送したきりで、毎フレーム変わっているのは uniform の値 2 つだけです。 回転中に横へ伸びて見えるのは、canvas が横長 (3:2) のままクリップ空間を引き伸ばしているためです(第3章と同じ理由。補正は第7章)。

この章で学ぶこと:

1. uniform — ドローコール全体で共通の値

シェーダーへ CPU から値を渡す経路は、大きく 2 つあります。1 つは第3章で使ったattribute (頂点属性)で、VBO から頂点ごとに違う値が届きます。もう 1 つがこの章のuniformで、1 回のドローコールの間、全頂点・全フラグメントで同じ値を取ります。 名前の uniform は「一様な」という意味で、値が実行単位ごとに変わらないことを指しています。

attribute(頂点ごとに違う)uniform(全実行で共通)(0.0, 0.8) / 赤(-0.8, -0.8) / 緑(0.8, -0.8) / 青頂点シェーダー #0頂点シェーダー #1頂点シェーダー #2頂点ごとに 1 回ずつ実行されるu_time = 2.7
attribute は実行(頂点)ごとに別の値、uniform は 1 つの値が全実行へ配られます。フラグメントシェーダーでも同じで、何十万回の実行すべてが同じ uniform 値を見ます。

GLSL 側の宣言は 1 行です。attribute と違って頂点ごとのバッファも VAO の配線も要らないので、CPU 側の準備もずっと軽くなります。

時間は u_time(秒)、マウスは u_mouse、解像度を渡す場合はu_resolution(描画バッファのピクセル数、vec2)— という名前を、本サイトでは今後も一貫して使います(u_mouseをどの座標系で渡すかだけは用途で変わります。この章はクリップ空間ですが、第6章で全画面シェーダー向けの定義に切り替えます)。第6章でこの 3 つを標準セットとして毎回自動で渡すハーネスを作り、第2部以降の全章がその上で動きます。

2. uniform API — ロケーションの取得と値の設定

uniform へ値を入れる手順は 2 段階です。

  1. ロケーションを取得するgl.getUniformLocation(program, 'u_time')。リンク済みプログラムの中から uniform を名前で探し、書き込み先のハンドル (WebGLUniformLocation) を返します。
  2. 値を設定するgl.uniform1f(location, 値)のような型別の関数で書き込みます。
src/lessons/04-animation-and-uniforms/main.ts(抜粋)
// uniform のロケーションは初期化時に 1 回だけ取得してキャッシュする
// (getUniformLocation は名前の文字列検索なので、毎フレーム呼ぶものではない)
const timeLocation = gl.getUniformLocation(program, 'u_time');
const mouseLocation = gl.getUniformLocation(program, 'u_mouse');

ロケーションの取得は名前の文字列検索なので、初期化時に 1 回だけ行い、変数にキャッシュします。毎フレームgetUniformLocationを呼ぶコードは動きはしますが、無駄な検索を毎秒何十回も繰り返すことになります。attribute のlayout(location = 0)のように番号をシェーダー側で固定する方法は、GLSL ES 3.00 の uniform にはありません(uniform をまとめてブロックごとバインドする UBO という仕組みが第34章で登場します)。

値の設定関数は、シェーダー側の型に合わせて選びます。名前の末尾が「成分数 + 型」になっています。

GLSL の型設定する関数
floatuniform1f(loc, x)この章の u_time
vec2uniform2f(loc, x, y)この章の u_mouse
vec3 / vec4uniform3f / uniform4f色 (RGB / RGBA) など
int / bool / sampler2Duniform1i(loc, i)テクスチャユニット番号(第16章)
uint / uvec2 など(WebGL2 で追加)uniform1ui / uniform2ui などビット演算を使う場面(第25章の hash など)
同じ型の uniform 配列uniform1fv / uniform2fv など末尾 v の系列複数光源のパラメータ(第18章)
mat2 / mat3 / mat4uniformMatrix2fv / 3fv / 4fv変換行列(第12章)

もう 1 つ、状態機械としての性質を押さえておきます。uniform の値はプログラムオブジェクトごとに保存される状態です。uniform1fを呼んだ瞬間にどこかへ送信されるのではなく、「そのプログラムのu_timeは今この値」という設定が書き換わり、次のドローコールがそれを読みます。一度設定した値は次に上書きするまで残るので、毎フレーム変わらない uniform は初期化時に 1 回設定すれば十分です。また、この設定関数はuseProgram現在使用中のプログラムに対して働くため、呼ぶ順序は必ず「useProgramuniform1f」です(使用中のプログラムがない状態で呼ぶとエラーになります)。 リンク直後の uniform はすべて 0 で初期化されています。

3. 描画ループ — requestAnimationFrame

アニメーションの骨格は WebGL ではなくブラウザの API です。requestAnimationFrame(以下 rAF)にコールバックを渡すと、ブラウザは次の画面更新の直前に 1 回だけそれを呼びます。コールバックの最後で自分自身を再予約すると、画面のリフレッシュレートに 同期したループになります(多くの環境で毎秒 60 回、ディスプレイによっては 120 回以上)。

src/lessons/04-animation-and-uniforms/main.ts(抜粋)
function frame(timestamp: DOMHighResTimeStamp): void {
  resizeIfNeeded();

  const time = timestamp / 1000; // rAF の引数はミリ秒。秒に直して使う

  gl.clear(gl.COLOR_BUFFER_BIT);

  // uniform の設定は「いま使っているプログラム」に対して行われる
  gl.useProgram(program);
  gl.uniform1f(timeLocation, time);
  gl.uniform2f(mouseLocation, mouseX, mouseY);

  gl.bindVertexArray(vao);
  gl.drawArrays(gl.TRIANGLES, 0, 3);

  requestAnimationFrame(frame); // 次のフレームを予約する
}

requestAnimationFrame(frame); // ループ開始

ポイントは 3 つあります。

4. 時間で動かす — u_time を頂点シェーダーへ

受け取った時間をどう動きに変えるかは、シェーダー側の仕事です。この章の頂点シェーダーは、第3章のものに 3 ステップの計算を足しています。

src/lessons/04-animation-and-uniforms/triangle.vert
#version 300 es

// 頂点属性: 頂点ごとに違う値。VBO 経由で渡される(第3章と同じ)
layout(location = 0) in vec2 a_position;
layout(location = 1) in vec3 a_color;

// uniform: ドローコール全体で共通の値。CPU 側から設定する
uniform float u_time; // 経過時間(秒)
uniform vec2 u_mouse; // マウス位置(クリップ空間 -1〜+1)

out vec3 v_color;

void main() {
  v_color = a_color;

  // ① ゆっくり回転する。sin / cos を直接書いた 2D 回転
  //    (この計算が mat2 という行列 1 つにまとまることを第10章で学ぶ)
  float angle = u_time * 0.5;
  float c = cos(angle);
  float s = sin(angle);
  vec2 pos = vec2(
    a_position.x * c - a_position.y * s,
    a_position.x * s + a_position.y * c
  );

  // ② sin で拡大率を揺らして脈動させる(0.55〜0.75 倍)
  pos *= 0.65 + 0.1 * sin(u_time * 2.0);

  // ③ マウスの方向へ寄せる(u_mouse はクリップ空間なのでそのまま足せる)
  pos += u_mouse * 0.3;

  gl_Position = vec4(pos, 0.0, 1.0);
}

フラグメントシェーダー側でも u_time を使って、色をゆっくり明滅させています。

src/lessons/04-animation-and-uniforms/triangle.frag
#version 300 es

precision highp float;

// uniform は頂点・フラグメントのどちらのシェーダーでも宣言できる。
// 同じ名前・同じ型で両方に宣言すると、リンク後は 1 つの uniform として共有される
uniform float u_time;

in vec3 v_color;

out vec4 fragColor;

void main() {
  // 頂点色をゆっくり明滅させる(0.75〜1.0 倍)
  float pulse = 0.875 + 0.125 * sin(u_time * 3.0);
  fragColor = vec4(v_color * pulse, 1.0);
}

5. マウスを渡す — DOM 座標からクリップ空間へ

マウスの扱いで本質的なのは、イベント処理よりも座標系の変換です。ブラウザのポインタイベントが くれる座標は「左上が原点、y は下向き、単位は CSS ピクセル」。一方、頂点シェーダーが足し算に使いたいのは 「中央が原点、y は上向き、-1〜+1」のクリップ空間です。第2章で「viewport や scissor の原点は左下で DOM と逆」という注意がありましたが、あれと同じ食い違いがここにも現れます。WebGL の座標系はすべて y が上向き、DOM は下向き — なので、DOM から WebGL へ座標を持ち込むときは必ずどこかで y を反転することになります。

DOM 座標(canvas ローカル)(0, 0)+x+yマウス(clientWidth, clientHeight)左上原点・y は下向き・単位は CSS ピクセルx: 0〜w → -1〜+1y: 0〜h → +1〜-1(y は反転)クリップ空間(0, 0)マウス(-1, -1)(+1, +1)中央原点・y は上向き(頂点座標と同じ系)
同じマウス位置を 2 つの座標系で見た図。x は範囲を写し替えるだけですが、y は向きも反転します。
src/lessons/04-animation-and-uniforms/main.ts(抜粋)
let mouseX = 0; // クリップ空間 (-1〜+1) で保持する。初期値は中央
let mouseY = 0;

canvas.addEventListener('pointermove', (event) => {
  const rect = canvas.getBoundingClientRect();

  // ① canvas の描画領域内の位置 → 0〜1 に正規化。
  //    rect はボーダーを含むので、clientLeft/Top(ボーダー幅)を引き、
  //    ボーダーを除いた表示サイズ clientWidth/Height で割る
  const x = (event.clientX - rect.left - canvas.clientLeft) / canvas.clientWidth;
  const y = (event.clientY - rect.top - canvas.clientTop) / canvas.clientHeight;

  // ② 0〜1 → クリップ空間 (-1〜+1)。y は向きが逆なので反転する
  mouseX = x * 2 - 1; // -1(左)〜+1(右)
  mouseY = 1 - y * 2; // -1(下)〜+1(上)
});

マウス座標を「クリップ空間の vec2」として渡すのは、この章の定義です。頂点座標と同じ座標系なので 足し算 1 つで反応が作れる、いま一番便利な形だからです。何をどの座標系で渡すかは設計次第で、 たとえばピクセル座標のまま渡して シェーダー側で変換する流儀もあります。

6. リサイズをループに統合する

第2章では ResizeObserver でサイズ変化を検知し、そのたびに描き直す方式を取りました。さらに 「dpr だけが変わるケースは ResizeObserver では拾えない」ため、matchMedia の監視まで併用していたことを思い出してください。毎フレーム描くようになった今は、もっと単純にできます。ループの先頭で毎回サイズを計算し、変わっていたら作り直すだけです。

src/lessons/04-animation-and-uniforms/main.ts(抜粋)
function resizeIfNeeded(): void {
  const dpr = Math.min(window.devicePixelRatio, 2);
  const width = Math.floor(canvas.clientWidth * dpr);
  const height = Math.floor(canvas.clientHeight * dpr);

  // width / height への代入は描画バッファの作り直し(中身も消える)なので、変化時のみ
  if (canvas.width !== width || canvas.height !== height) {
    canvas.width = width;
    canvas.height = height;
    // 描画バッファを作り直したら viewport も合わせ直す(第2章)
    gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
  }
}

比較すると要点がはっきりします。「サイズ変化 → 描き直し」というイベント駆動の設計では、 変化を漏れなく検知する仕掛け(ResizeObserver + matchMedia)が必要でした。毎フレーム描くループ駆動の設計では、検知は不要です — どうせ次のフレームで気づくからです。表示サイズの変化・ウィンドウリサイズ・ ブラウザのズーム・dpr の異なるディスプレイへの移動、どこから来た変化でも同じ 1 箇所で拾えます。本サイトのループを持つデモは、以降すべてこの形です(静止画を 1 枚描くだけのデモでは、引き続き ResizeObserver が適役です)。チェックの中身はclientWidth / clientHeightの読み取りと数値の比較だけです。これらはレイアウトの結果を読むプロパティなので無条件に安いわけではありませんが、同じコールバックの中で、読むより先に DOM を書き換えていないかぎり、毎フレーム呼んでも負荷は問題になりません(条件と実測値は次の補足)。

コード全文

シェーダー 2 本は上に掲載したとおりです。main.ts の全文を載せます。コンパイル・リンクの関数と頂点まわりは第3章のままで、変わったのは後半 — 「初期化(1 回)」と「ループ(毎フレーム)」の 2 部構成になったことです。この構造は、この先どんなに 複雑な作品になっても変わりません。

src/lessons/04-animation-and-uniforms/main.ts
// 第4章: アニメーションと uniform
// 第3章の静止した三角形に uniform で時間とマウス座標を渡し、
// requestAnimationFrame のループで毎フレーム描画する。
// リサイズ処理は「ループの先頭でサイズが変わっていれば作り直す」形に統合する(第2章の続き)。

import fragmentSource from './triangle.frag?raw';
import vertexSource from './triangle.vert?raw';

// ---------------------------------------------------------------------------
// シェーダーのコンパイルとプログラムのリンク(第3章と同じ)
// ---------------------------------------------------------------------------

function compileShader(gl: WebGL2RenderingContext, type: GLenum, source: string): WebGLShader {
  const shader = gl.createShader(type);
  if (!shader) {
    throw new Error('シェーダーオブジェクトを作成できませんでした');
  }
  gl.shaderSource(shader, source);
  gl.compileShader(shader);
  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
    const log = gl.getShaderInfoLog(shader);
    gl.deleteShader(shader);
    throw new Error(`シェーダーのコンパイルに失敗しました:\n${log ?? '(ログなし)'}`);
  }
  return shader;
}

function linkProgram(
  gl: WebGL2RenderingContext,
  vertexShader: WebGLShader,
  fragmentShader: WebGLShader,
): WebGLProgram {
  const program = gl.createProgram();
  if (!program) {
    throw new Error('プログラムオブジェクトを作成できませんでした');
  }
  gl.attachShader(program, vertexShader);
  gl.attachShader(program, fragmentShader);
  gl.linkProgram(program);
  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
    const log = gl.getProgramInfoLog(program);
    gl.deleteProgram(program);
    throw new Error(`プログラムのリンクに失敗しました:\n${log ?? '(ログなし)'}`);
  }
  gl.deleteShader(vertexShader);
  gl.deleteShader(fragmentShader);
  return program;
}

// ---------------------------------------------------------------------------
// デモ本体
// ---------------------------------------------------------------------------

function setup(gl: WebGL2RenderingContext, canvas: HTMLCanvasElement): void {
  // --- 初期化(1 回だけ実行する部分) ---------------------------------------

  const program = linkProgram(
    gl,
    compileShader(gl, gl.VERTEX_SHADER, vertexSource),
    compileShader(gl, gl.FRAGMENT_SHADER, fragmentSource),
  );

  // 頂点データと VAO の配線は第3章とまったく同じ
  // biome-ignore format: 1 頂点 = 1 行の並びを保つ
  const positions = new Float32Array([
    0.0, 0.8, // 上
    -0.8, -0.8, // 左下
    0.8, -0.8, // 右下
  ]);
  // biome-ignore format: 1 頂点 = 1 行の並びを保つ
  const colors = new Float32Array([
    1.0, 0.25, 0.35, // 上: 赤
    0.25, 0.9, 0.5, // 左下: 緑
    0.3, 0.5, 1.0, // 右下: 青
  ]);

  const vao = gl.createVertexArray();
  gl.bindVertexArray(vao);

  const positionBuffer = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
  gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW);
  gl.enableVertexAttribArray(0);
  gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);

  const colorBuffer = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer);
  gl.bufferData(gl.ARRAY_BUFFER, colors, gl.STATIC_DRAW);
  gl.enableVertexAttribArray(1);
  gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 0, 0);

  gl.bindVertexArray(null);

  // uniform のロケーションは初期化時に 1 回だけ取得してキャッシュする
  // (getUniformLocation は名前の文字列検索なので、毎フレーム呼ぶものではない)
  const timeLocation = gl.getUniformLocation(program, 'u_time');
  const mouseLocation = gl.getUniformLocation(program, 'u_mouse');

  // クリア色も「状態」なので、毎フレーム設定し直す必要はない
  gl.clearColor(0.06, 0.07, 0.09, 1.0);

  // --- マウス: DOM 座標をクリップ空間へ変換して保持する --------------------

  let mouseX = 0; // クリップ空間 (-1〜+1) で保持する。初期値は中央
  let mouseY = 0;

  canvas.addEventListener('pointermove', (event) => {
    const rect = canvas.getBoundingClientRect();

    // ① canvas の描画領域内の位置 → 0〜1 に正規化。
    //    rect はボーダーを含むので、clientLeft/Top(ボーダー幅)を引き、
    //    ボーダーを除いた表示サイズ clientWidth/Height で割る
    const x = (event.clientX - rect.left - canvas.clientLeft) / canvas.clientWidth;
    const y = (event.clientY - rect.top - canvas.clientTop) / canvas.clientHeight;

    // ② 0〜1 → クリップ空間 (-1〜+1)。y は向きが逆なので反転する
    mouseX = x * 2 - 1; // -1(左)〜+1(右)
    mouseY = 1 - y * 2; // -1(下)〜+1(上)
  });

  // --- リサイズ: ループの先頭で「変わっていれば作り直す」(第2章の統合形) ----

  function resizeIfNeeded(): void {
    const dpr = Math.min(window.devicePixelRatio, 2);
    const width = Math.floor(canvas.clientWidth * dpr);
    const height = Math.floor(canvas.clientHeight * dpr);

    // width / height への代入は描画バッファの作り直し(中身も消える)なので、変化時のみ
    if (canvas.width !== width || canvas.height !== height) {
      canvas.width = width;
      canvas.height = height;
      // 描画バッファを作り直したら viewport も合わせ直す(第2章)
      gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
    }
  }

  // --- 描画ループ: 毎フレーム実行する部分 ----------------------------------

  function frame(timestamp: DOMHighResTimeStamp): void {
    resizeIfNeeded();

    const time = timestamp / 1000; // rAF の引数はミリ秒。秒に直して使う

    gl.clear(gl.COLOR_BUFFER_BIT);

    // uniform の設定は「いま使っているプログラム」に対して行われる
    gl.useProgram(program);
    gl.uniform1f(timeLocation, time);
    gl.uniform2f(mouseLocation, mouseX, mouseY);

    gl.bindVertexArray(vao);
    gl.drawArrays(gl.TRIANGLES, 0, 3);

    requestAnimationFrame(frame); // 次のフレームを予約する
  }

  requestAnimationFrame(frame); // ループ開始
}

// ---------------------------------------------------------------------------
// 要素とコンテキストの取得
// ---------------------------------------------------------------------------

const canvas = document.querySelector<HTMLCanvasElement>('#demo');
if (!canvas) {
  throw new Error('canvas 要素 #demo が見つかりません');
}

const gl = canvas.getContext('webgl2');
if (!gl) {
  throw new Error('このブラウザは WebGL2 に対応していません');
}

setup(gl, canvas);

three.js との対応

three.js で ShaderMaterialに uniforms を渡した経験があれば、この章はその配管を直接触った、と言えます。

three.jsこの章の生 WebGL2
renderer.setAnimationLoop(animate)(または自前の rAF ループ)requestAnimationFrame で自分自身を再予約するループ
THREE.ClockgetElapsedTime()rAF の timestamp 引数を 1000 で割って秒にする
ShaderMaterialuniforms: { u_time: { value: 0 } }getUniformLocation でロケーションを取得してキャッシュ
毎フレーム material.uniforms.u_time.value = t毎フレーム useProgramuniform1f(timeLocation, t)
three が自動で更新する uniform(modelViewMatrixprojectionMatrixなど)まだ登場しない — 第11〜12章で行列として自作し、自分で渡す

手元で動かして、壊してみる

この章のコードはsrc/lessons/04-animation-and-uniforms/にあります。動くものは、止め方・壊れ方を見るのが一番勉強になります。

まとめ

シェーダーに外から値が届くようになったので、表現の主役は GLSL のコードそのものに移っていきます。次章はいったん手を止めて、GLSL ES 3.00 という言語を一度きちんと整理します — 型とベクトル、スウィズル、組み込み関数。第2部からシェーダーを本格的に書き始めるための足場です。