第5部 大量描画・GPGPU・統合 — 第36章

インタラクションと出力 — ポインタ・オーディオ反応・書き出し

ここまでの 35 章は、ほとんどが「GPU に何をどう描かせるか」の話でした。この章の道具はほとんどが WebGL の外側にあります— 入力(ポインタとキーボード)・音・DOM の部品・書き出し、の 4 つです。これらを支える API は WebGL2 の仕様には 1 行も出てきません(例外は 7 節の高解像度書き出しで、そこだけは FBO とreadPixelsというコアの WebGL2 を使います)。 それでも、作ったものを人が触れるものにするには、この 4 つが要ります。

4 つには共通の設計がひとつあります。イベントは状態を更新するだけで、絵は 1 フレームに 1 回、その状態を読んで描く。ポインタも、キーも、GUI のつまみも、全部この形に揃えます。イベントは非同期に何度でも来ますが、 描くのは 1 フレームに 1 回だけなので、この 2 つを直接つなぐと「1 フレームのあいだに何回イベントが来たか」で絵が変わってしまいます。

4 本のデモは同じシーンを見ています。違うのは「何がそのシーンを動かすか」だけ。 ドラッグで回転、ホイールまたは 2 本指のピンチで寄り引き、2 本指のドラッグで平行移動、キーはW/A/S/Dで注視点の移動、Q/Eで寄り引き、Rでリセット、Shiftを押しながらだとW/A/S/Dが 3 倍速です。読み出し行にはデモごとに違う数字が出ます — 押されているポインタの数、FFT のビン数と 1 ビンあたりの Hz、書き出したサイズとタイル数と PNG のバイト数。 音のデモで「波形」を選ぶと、変形はスペクトルよりずっと小さく見えます— 音量を絞ってある(マスターのgain0.1)ぶん、時間波形は無音の 128 を中心に ±5 程度にしか振れないからです(GPU へ送っている値を実測。同じときスペクトルの行は最大 166〜172。Chrome / ANGLE Metal / Apple M2 / 2026-08-08)。形が動くのを見たいときはスペクトルのほうを選んでください。「書き出す」を押しても保存は始まりません— 書き出した結果の大きさが読み出し行に出て、「保存」リンクが現れます。

この章で学ぶこと:

1. 入力を 1 か所にまとめる — Pointer Events で 3 種類のデバイスを 1 本に

第15章 3 節で、軌道カメラのためにpointerdown/pointermove/setPointerCaptureを書きました。あれはカメラ専用の直書きで、扱えるポインタは 1 本だけでした。この節の仕事はその一般化です。

Pointer Events は「マウスもペンもタッチも、同じ形のイベントで届ける」ための仕様です。導入節が、 なぜそれが要るのかを書いています —既存のマウス向けコンテンツとの互換のため、ほとんどのユーザーエージェントはあらゆる入力に対して Mouse Events を発火する。そのせいで、ある Mouse Event が本物のマウスなのか、互換のために別の入力から作られたものなのかが曖昧になる(Pointer Events Level 3・W3C Recommendation 2026 年 6 月 30 日・§1 Introduction)。MouseEventとTouchEventを混ぜて書くと、この曖昧さを自分のコードの中に持ち込むことになります。だから片方に寄せます。

どのデバイスかを知りたいときはpointerTypeを見ます。§4.1 は、マウスなら"mouse"、ペン / スタイラスなら"pen"、タッチの接触なら"touch"を返さなければならない、と表で定めています。デバイスの種類を検出できない場合は空文字列であることも同じ節に書いてあるので、既定の枝を必ず用意します(読み出し行では(不明)と出しています)。

pointerId ごとに状態を持つ

複数指を扱う土台はこれだけです —pointerIdをキーにしたMapを持つ。

src/lessons/36-interaction-and-output/input.ts(抜粋)
canvas.addEventListener('pointerdown', (event) => {
  // マウスの右クリック・中クリックは無視する。touch / pen は button = 0
  if (event.pointerType === 'mouse' && event.button !== 0) return;
  pointers.set(event.pointerId, {
    type: event.pointerType,
    downClientX: event.clientX,
    downClientY: event.clientY,
    clientX: event.clientX,
    clientY: event.clientY,
    x: 0,
    y: 0,
    downX: 0,
    downY: 0,
  });
  // 捕獲しておくと、canvas の外へ出ても pointermove / pointerup が届く(第15章 3 節)
  canvas.setPointerCapture(event.pointerId);
  resetGesture();
});

pointerIdの値そのものに意味はありません。仕様(§4.1)は「アクティブなポインタどうしで一意であること」しか 要求しておらず、注記でこう釘を刺しています —値の割り当てアルゴリズムは実装依存で、作者は「他のアクティブなポインタと重ならない識別子」以外の 意味を仮定できない。単調増加も保証されません。だからMapのキーとして使うだけにして、番号から「何本目の指か」を読もうとしないこと。

touch-action: none — 先に取られないために

タッチで canvas をドラッグすると、ふつうはブラウザがそれをページのスクロールとして解釈します。 そうなると、こちらにはpointercancelが飛んできて以降のイベントは来ません。これを止めるのが CSS のtouch-actionで、noneの定義は「その要素の上で始まった直接操作は、ビューポートのパンとズームに関する挙動を引き起こしてはならない」(§8.3)。このサイトではすでに指定されています—src/styles/global.cssのfigure.demo canvas.interactiveにtouch-action: noneがあり、第15章から使っていました。この章の canvas も同じinteractiveクラスを付けています。

座標をどこで直すか

イベントが持っているのはclientX/clientY— ビューポート基準の CSS ピクセルです。欲しいのは描画バッファのピクセル(第2章 3 節)。変換には canvas の位置と大きさが要るのでgetBoundingClientRect()を呼ぶことになりますが、これはレイアウトを読むプロパティで、 直前に書き換えがあれば強制同期レイアウトを誘発します(第4章 6 節・計測手順は第35章 4 節)。pointermoveのたびに呼びたくはありません。

そこでキャッシュして、汚れたときだけ読み直す形にします。ここで見落としやすいのが、getBoundingClientRect()はビューポート基準だという点です。canvas の大きさが変わらなくても、ページをスクロールしただけで値がずれます。だからResizeObserverだけでは足りません。

src/lessons/36-interaction-and-output/input.ts(抜粋)
function markRectDirty(): void {
  rectDirty = true;
}

function refreshRect(): void {
  if (!rectDirty) return;
  const box = canvas.getBoundingClientRect();
  rectReads++;
  rectLeft = box.left;
  rectTop = box.top;
  rectWidth = Math.max(1, box.width);
  rectHeight = Math.max(1, box.height);
  rectDirty = false;
}

const resizeObserver = new ResizeObserver(markRectDirty);
resizeObserver.observe(canvas);
// capture: true で、内側のスクロールコンテナが動いたときも拾う
window.addEventListener('scroll', markRectDirty, { capture: true, passive: true });
window.addEventListener('resize', markRectDirty);

そして実際に読み直すのは、イベントの中ではなくフレームの先頭です。イベントはclientX/clientYを覚えるだけで、変換は 1 フレームに 1 回。

src/lessons/36-interaction-and-output/input.ts(抜粋)
beginFrame() {
  refreshRect();
  for (const state of pointers.values()) {
    state.x = toBufferX(state.clientX);
    state.y = toBufferY(state.clientY);
    state.downX = toBufferX(state.downClientX);
    state.downY = toBufferY(state.downClientY);
  }
},

デモ 1 の読み出し行にgetBoundingClientRectの呼び出し回数が出ています。ページを開いてから何回読んだかの累計で、canvas をどれだけドラッグしても増えません(増えるのはリサイズとスクロールのとき)。

src/lessons/36-interaction-and-output/input.ts(抜粋)
function endPointer(event: PointerEvent): void {
  if (!pointers.delete(event.pointerId)) return;
  if (canvas.hasPointerCapture(event.pointerId)) {
    canvas.releasePointerCapture(event.pointerId);
  }
  // 1 本抜けたら基準を取り直す。取り直さないと、残った指の距離が
  // いきなり別の意味になって絵が飛ぶ(2 節)
  resetGesture();
}

この節の結論

入力は「イベント」ではなく「フレームごとに読む状態」にする。イベントハンドラの仕事は 2 つだけ — 状態を書き換えることと、溜まった量を足すこと。 そこから先(カメラをどう動かすか、絵にどう反映するか)は 1 行も書きません。こうしておくと、同じ入力層をカメラ以外にも使い回せますし、フレームが飛んだときの 挙動も 1 か所で決められます。GUI(5 節)もまったく同じ形にします。

2. 2 本指 — ピンチとパン

第15章のカメラは 1 本指のドラッグとホイールだけで、タッチのピンチズームには対応していませんでした。ここで対応させます。必要な材料は 1 節でもう揃っています —Mapに 2 本のポインタが入っていれば、それがピンチです。

2 本指から取れる量は 2 つあります。

倍率はジェスチャ開始時の距離との比で取ります。基準が 1 つあるだけなので、指が元の位置に戻れば倍率もちょうど 1 に戻りますし、指の本数が変わったときは、その基準を置き直すだけで済みます(次の項)。毎フレームの差分d(t) - d(t-1)を積み上げる書き方には基準そのものが無いので、置き直しようがありません。

src/lessons/36-interaction-and-output/input.ts(抜粋)
const pair = twoPointers();
if (!gesture || !pair) return;
const dx = pair[1].clientX - pair[0].clientX;
const dy = pair[1].clientY - pair[0].clientY;
const distance = Math.max(1, Math.hypot(dx, dy));
const midX = (pair[0].clientX + pair[1].clientX) / 2;
const midY = (pair[0].clientY + pair[1].clientY) / 2;

// 倍率は**開始時の距離との比**。基準が 1 つなので指の本数が変わっても置き直すだけで済む(2 節)
const scale = distance / gesture.distance;
// 前回の倍率との差だけをズーム量へ渡す。log の差なので加算でよい
zoomLog -= Math.log(scale) - Math.log(pinchScale);
pinchScale = scale;

panX += midX - gesture.midX;
panY += midY - gesture.midY;
gesture.midX = midX;
gesture.midY = midY;

zoomLogに入れているのは距離の対数です。ズームは加算ではなく乗算で効くので(第15章 4 節が同じことをしています)、対数にしておくと「ホイールで少し引いた」と「ピンチで 1.2 倍した」を単純に足し算できます。使う側はradius *= Math.exp(zoomLog)で戻すだけです。

基準を取り直す場所

指の本数が変わったら、必ず基準を取り直します。3 本目が置かれたとき、1 本抜けたとき、どちらもです。この章の実装ではresetGesture()をpointerdownとendPointer(pointerup/pointercancel/lostpointercapture)の両方から呼んでいます。取り直さないと、残った指の距離が 「前のペアの基準」で割られて、いきなり別の倍率になります。

ホイールとの統一 — deltaMode

ホイールも「距離を乗算する」入力なので、同じzoomLogに足したいところです。ただしWheelEventのdeltaYは、そのままでは単位が決まっていません。単位を持っているのは別の属性deltaMode(0 = ピクセル / 1 = 行 / 2 = ページ)のほうで、第15章 4 節が「行なら ×16、ページなら ×100 して px 相当に揃える」で吸収していたのは、これです。使い方と、吸収しないとズームがほとんど効かないことは、あちらに書きました。ここで見るのはその係数を自分で決めるしかない理由のほうです。

定数の定義は Pointer Events Level 4(W3C Working Draft 2026 年 7 月 1 日)§6.1.1 にあります。UI Events 仕様の Mouse Events と Wheel Events の節は Pointer Events 側へ移されました(UI Events・W3C Working Draft 2026 年 2 月 21 日 §1.1.1)ので、いまの一次情報はこちらです。 そこで決まっているのは 0 が「ピクセル」、1 が「個々のテキスト行」、2 が「1 画面または区切られた 1 ページ」という単位の種類だけで、deltaMode自身の定義には次の一文が添えられています —「正確な測定はデバイス・オペレーティングシステム・アプリケーションの構成に固有である」。つまり「1 行が何ピクセルか」は仕様が決めていません。決めていないものを 決めるのは、こちらの仕事です。

実際、トラックパッドの 2 本指スクロールはたいていDOM_DELTA_PIXELで細かい値を大量に送ってきますし、ホイール付きのマウスは環境によってDOM_DELTA_LINEで 1 とか 3 とかを送ってきます。同じ「1 段回した」でも数値が桁で違うのは このためです。

src/lessons/36-interaction-and-output/input.ts(抜粋)
canvas.addEventListener(
  'wheel',
  (event) => {
    // ページのスクロールに取られないようにする。passive: false が要る
    event.preventDefault();
    const scale =
      event.deltaMode === WheelEvent.DOM_DELTA_LINE
        ? LINE_HEIGHT_PX
        : event.deltaMode === WheelEvent.DOM_DELTA_PAGE
          ? PAGE_HEIGHT_PX
          : 1;
    zoomLog += event.deltaY * scale * WHEEL_ZOOM_PER_PIXEL;
  },
  { passive: false },
);

LINE_HEIGHT_PX = 16とPAGE_HEIGHT_PX = 100は仕様の値ではなく、このアプリの取り決めです(第15・32 章のホイール処理も同じ数を使っています)。仕様が決めていないものを勝手に決めているので、 ソースにその旨をコメントで書いてあります。

3. キーボード — 「押されている集合」をフレームで読む

キーもポインタと同じ形にします。keydown/keyupでSetに出し入れして、フレームで読む。

src/lessons/36-interaction-and-output/input.ts(抜粋)
window.addEventListener('keydown', (event) => {
  if (isTextEntry(event.target)) return;
  readModifiers(event);
  // event.repeat が true のイベントは無視する。集合はもう入っているし、
  // リピートの間隔は OS の設定で決まるので、絵の速さをそこに預けたくない(3 節)
  if (event.repeat) return;
  keys.add(event.code);
});

window.addEventListener('keyup', (event) => {
  readModifiers(event);
  keys.delete(event.code);
});

event.repeatがtrueのイベントを無視しているのは、キーリピートに絵の速さを預けないためです。UI Events(W3C Working Draft 2026 年 2 月 21 日)§3.5.1.1 はrepeat属性についてこう定めています —キーを押し続けると、keydown/beforeinput/inputをこの順で、システムの設定で決まる速さで繰り返さなければならない。この「システムの設定」は OS のキーリピート設定そのものなので、keydownのたびにカメラを動かすと、OS の設定でカメラの速さが変わるプログラムになります。集合で持ってフレームで読めば、 速さを決めるのはdtだけになります。

src/lessons/36-interaction-and-output/main.ts(抜粋)
// キーはフレームで集合を読む。押されている間ずっと効く(3 節)
const boost = input.modifiers.shift ? 3 : 1;
const move = KEY_MOVE_SPEED * dt * boost;
let forward = 0;
let strafe = 0;
if (input.keys.has('KeyW')) forward += 1;
if (input.keys.has('KeyS')) forward -= 1;
if (input.keys.has('KeyD')) strafe += 1;
if (input.keys.has('KeyA')) strafe -= 1;

どこでキーを拾うか

canvas は既定でフォーカスを持たないので、canvas にkeydownを付けても何も来ません。手は 2 つです。

  1. canvas にtabindexを付けてフォーカス可能にし、canvas でイベントを拾う。そのページの他の部分と競合しない代わりに、読者が canvas を 1 回クリックしないとキーが効かない
  2. windowで拾う。すぐ効く代わりに、ページ内の入力欄で打っている文字まで拾ってしまう

この章は 2 を選んでいます。デモを見ながらすぐキーを試せるほうが読み物として素直だからです。 代償として、入力欄にフォーカスがあるときは自分で外しています(isTextEntry)。 このページには入力欄が実際にあります — 5 節の GUI のスライダーと色の入力です。

タブを離れたとき

windowで拾うと、もうひとつ落とし穴があります。キーを押したままタブを切り替えると、keyupは来ません。集合にキーが残ったままになり、戻ってきたらカメラが勝手に動き続けます。 フォーカスを失ったら集合を空にします。

src/lessons/36-interaction-and-output/input.ts(抜粋)
// タブを離れると keyup が来ないまま押しっぱなしの状態が残る。
// フォーカスを失ったら集合を空にする(3 節)
function clearKeys(): void {
  keys.clear();
  modifiers.shift = false;
  modifiers.ctrl = false;
  modifiers.alt = false;
  modifiers.meta = false;
}
window.addEventListener('blur', clearKeys);
document.addEventListener('visibilitychange', () => {
  if (document.hidden) clearKeys();
});

修飾キーは集合に入れず、event.shiftKeyなどをイベントのたびに控えて、フレームで読みます。修飾キーは「いま押されているか」が イベントに載って届くので、集合として別管理するより素直です。

ここまでで 1〜3 節がsrc/lessons/36-interaction-and-output/input.tsという 1 本の部品にまとまりました。344 行の、他に依存しないファイルです。 第34章のミニエンジンとは無関係で、この章はエンジンを使っていません。

4. 音に反応させる — AnalyserNode と FFT

音源はこの章で作ります。マイクも音声ファイルも使いません — マイクは権限の確認が要り、 音声ファイルは素材の配布が要るからで、どちらもこの節の主題(音の数値をどう絵に届けるか)から 離れます。オシレータ数本とノイズがあれば、スペクトルには十分な構造が出ます。

AudioContext は勝手には鳴り出さない

Web Audio API(W3C Recommendation 2021 年 6 月 17 日)はallowed to startという言葉を定義しています —「AudioContextがallowed to startであるとは、ユーザーエージェントがそのコンテキストの状態を "suspended" から "running" へ遷移させることを許すことをいう。ユーザーエージェントはこの最初の遷移を禁止してもよく、 そのAudioContextの relevant global object が sticky activation を持つときにだけ許してもよい」(§1.2)。resume()の手順にも同じ判定が入っていて、allowed to start でなければ Promise は保留に積まれるだけで、状態は変わりません。

つまり、ページを開いた瞬間にnew AudioContext()しても音は出ない可能性があります。この章ではAudioContextを「再生」ボタンが最初に押されたときに作り、その場でresume()します。ユーザー操作のハンドラの中で呼ぶことが要点です。

src/lessons/36-interaction-and-output/audio.ts(抜粋)
start() {
  const { context: ctx } = ensureContext();
  // ユーザー操作の中から呼ぶ。ここが autoplay policy の入口(4 節)
  void ctx.resume();
  if (!playing) {
    nextStepAt = ctx.currentTime + 0.05;
    playing = true;
  }
},

AnalyserNode を挟む

AnalyserNodeは「音声ストリームを未加工のまま入力から出力へ通す」ノードです(§1.8)。 音の経路の途中に挟んでも音は変わらず、その時点の波形とスペクトルを覗けます。この章の接続は 1 本道です — ボイス →masterGain(0.1)→analyser→destination。

src/lessons/36-interaction-and-output/audio.ts(抜粋)
const created = new AudioContext();
const gain = created.createGain();
gain.gain.value = MASTER_GAIN;
const node = created.createAnalyser();
node.fftSize = FFT_SIZE;
// 既定のまま(minDecibels -100 / maxDecibels -30 / smoothingTimeConstant 0.8)。
// 既定値は仕様が決めている(§1.8.2)ので、ここで書き換える理由が無い
gain.connect(node);
node.connect(created.destination);

属性はすべて §1.8.2 に定義があります。

この章は 5 つとも既定のままで、fftSizeだけ明示的に 2048 と書いています(既定と同じ値ですが、テクスチャの幅がこの値から決まるので ソースに出しておきたい)。frequencyBinCountは 1024 になります。

0〜255 は何の値なのか

ここを曖昧にすると、「値の意味が分からないまま絵に掛ける」ことになります。§1.8.6 が、周波数データを作る手順を 5 段で定めています。

  1. 現在の時間領域データ(直近 fftSize フレーム)を取る
  2. ブラックマン窓を掛ける
  3. フーリエ変換して複素の周波数データ X[k] を得る
  4. 時間方向に平滑化する。X̂[k] = τ · X̂₋₁[k] + (1 − τ) · |X[k]|(τがsmoothingTimeConstant)
  5. dB に変換する。Y[k] = 20 · log₁₀ X̂[k]

このY[k](dB)をそのまま返すのがgetFloatFrequencyDataで、getByteFrequencyDataはさらに 1 段掛かります。§1.8.3 の式そのものです。

b[k] = ⌊ 255 / (dB_max − dB_min) × (Y[k] − dB_min) ⌋

範囲外は 0〜255 にクリップされます。つまり返ってくる 0〜255 は「dB の目盛りを 256 段に切ったもの」であって、振幅でも音圧でもありません。 既定値(−100〜−30)なら 1 段あたり(−30 − (−100)) / 255 = 0.2745 dBです。バイト値を振幅に戻すと、こうなります。

バイト値 bY(dB)平滑化後の振幅 X̂
0−100.001.000 × 10⁻⁵
64−82.437.558 × 10⁻⁵
128−64.865.713 × 10⁻⁴
192−47.294.318 × 10⁻³
255−30.003.162 × 10⁻²

既定のminDecibels = −100/maxDecibels = −30のとき。bからYを逆算し、Y = 20 log₁₀ X̂を解いてX̂を出した。再現スクリプト:scratchpad/p5b/ch36-counts.mjs

バイト値は振幅に対して線形ではありません。128 は 255 の半分ではなく、振幅では 約 1/55 です。「音量に比例して大きくする」つもりでバイト値をそのまま掛けると、実際にはdB に比例して大きくしていることになります。それが悪いわけではなく (人の聴覚は対数に近いので、むしろ見た目は素直になります)、何をしているかを知っていることが 大事です。

波形のほうはずっと単純で、§1.8.3 のgetByteTimeDomainDataはb[k] = ⌊128 · (1 + x[k])⌋。無音は 128で、±1の振幅が 0〜255 に写ります。シェーダー側で128/255 ≒ 0.502を引いているのはこのためです(u_audioBias)。

1 ビンあたりの周波数

frequencyBinCount個のビンが覆うのは 0 Hz からナイキスト周波数(sampleRate / 2。§1.1.1 が「ナイキスト周波数はこのサンプルレートの半分である」と定義)までなので、

1 ビンの幅 = (sampleRate / 2) / (fftSize / 2) = sampleRate / fftSize

sampleRateは環境が決めるので、実測値は読み出し行に出しています。よくある 2 つの値で計算しておくと、48,000 Hz なら 1 ビン23.438 Hz(上端 24.0 kHz)、44,100 Hz なら21.533 Hz(上端 22.1 kHz)。この章のいちばん低い音は 110 Hz なので、基音は 5 番目あたりのビンに落ちます。

GPU への渡し方

ビンの列は「幅 = ビン数・高さ 1 の 1 チャンネル画像」そのものなので、テクスチャに載せます。 この章は 2 行にして、行 0 にスペクトル、行 1 に波形を置きました。

src/lessons/36-interaction-and-output/main.ts(抜粋)
gl.texStorage2D(gl.TEXTURE_2D, 1, gl.R8, BIN_COUNT, 2);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
// 1 行が 1 バイト × 幅。既定の 4 だと、幅が 4 の倍数でないとき行の末尾に
// 詰め物が入る前提で読まれる(ES 3.0.6 §3.7.2.1 式 3.13)。
// ここは幅 1024 なのでたまたま 4 の倍数だが、幅を変えた瞬間に壊れるので 1 にしておく
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);

UNPACK_ALIGNMENTは、CPU 側のメモリで行の先頭が何バイト境界に乗っているかを GL に伝える値です。ES 3.0.6 §3.7.2.1 の式 (3.13) が、行 1 本ぶんに進む距離kを定めています — 1 要素のバイト数sがアライメントa以上ならk = n·l(そのまま)ですが、s < aのときはk = (a/s)·⌈s·n·l/a⌉、つまりaバイト境界まで切り上げられます。

R8は 1 テクセル 1 バイトなのでs = 1、既定のa = 4では幅が 4 の倍数でないと、行の末尾に詰め物があるものとして読まれます。正直に書くと、この章では既定の 4 のままでも壊れません— 幅はfrequencyBinCount = 1024で、fftSizeが 2 の冪である以上ビン数も必ず 4 の倍数になるからです。それでも 1 にしているのは、幅を変えた瞬間(たとえば下で触れる対数リサンプルで 100 ビンに詰め直した瞬間)に壊れるからで、原因が「絵が斜めにずれる」という形でしか出てこないためです。

src/lessons/36-interaction-and-output/main.ts(抜粋)
gl.texSubImage2D(
  gl.TEXTURE_2D,
  0,
  0,
  0,
  BIN_COUNT,
  1,
  gl.RED,
  gl.UNSIGNED_BYTE,
  frequencyData,
);
// 波形は fftSize 個あるので、テクスチャの幅に合わせて**新しいほうから**ビン数ぶん使う
gl.texSubImage2D(
  gl.TEXTURE_2D,
  0,
  0,
  1,
  BIN_COUNT,
  1,
  gl.RED,
  gl.UNSIGNED_BYTE,
  waveformData.subarray(audio.fftSize - BIN_COUNT),
);

波形はfftSize個(2048)返ってくるのに、テクスチャの幅はビン数(1024)しかありません。この章は新しいほうから 1024 サンプルだけを使っています(subarrayはコピーを作らないビューです)。2 行の幅を揃えるための割り切りで、波形の窓が半分になります。

シェーダー側は 1 行読むだけです。頂点シェーダーからテクスチャを読めることは仕様が保証しています — MAX_VERTEX_TEXTURE_IMAGE_UNITSの最小要求値は 16(ES 3.0.6 Table 6.31)。

src/lessons/36-interaction-and-output/scene.vert(抜粋)
float sampled = texture(u_audio, vec2(a_uv.x, u_audioRow)).r - u_audioBias;
  v_audio = sampled;

  // 法線方向へ押し出すだけで、法線そのものは計算し直していない。
  // 陰影が変位に追従しないぶん形は素直に読めるので、この章ではこれで足りる
  vec3 displaced = a_position + a_normal * (u_audioAmount * sampled);

この章で毎フレームまとまった配列を WebGL に渡しているのは、ここだけです。1 フレームに1024 × 2 = 2,048バイト。60 fps なら122,880バイト/秒(120.0 KiB/秒)です。デモ 1 の可視化も、ポインタが押されているあいだは毎フレームbufferSubDataを呼んでいますが、あちらは点 1 個あたり 6 float = 24 バイトで、ポインタ 1 本なら点 2 個の 48 バイト、2 本でも(中点を足して)点 5 個の 120 バイトです。第30章 6 節が粒子の位置を毎フレームbufferSubDataで送っていたのと同じ種類の転送で(いちばん多い 32,000 粒子で 1 フレーム 512,000 バイト)、第32章はそれを 0 バイトにしました。この章の 2 KB は、第30章とは桁が違う小ささです —音の情報量はもともと小さい、ということでもあります。

ビンは線形、耳は対数

ビンは周波数軸上で等間隔です。1024 個のビンのうち、いちばん低い 1 オクターブ(たとえば 20〜40 Hz)に入るのは 48,000 Hz のとき 1 個程度、いちばん高い 1 オクターブ(12〜24 kHz)には 512 個が入ります。人の聴覚はほぼ対数なので、そのまま並べると絵の右半分が「高音のほとんど何も無い領域」に費やされます。 デモでもトーラスの外周の後ろ半分がほとんど動かないのが見えるはずです。

手当ては「対数でリサンプルする」ことです — 出力のi番目のビンに、周波数f_min · (f_max/f_min)^(i/N)の前後を集める。この章では実装していません。テクスチャの幅を変えることになるので、 上のUNPACK_ALIGNMENTの話がそこで効きます。「壊してみる」に入れてあります。

5. つまみを作る — 最小の GUI

パラメータをいじれるようにしたくなったとき、まず思い浮かぶのは dat.GUI / lil-gui / Tweakpane あたりでしょう。この章はどれも使いません。理由は第6章 1 節の抽象化のポリシーと同じです —この節の主題は「つまみの値が絵に届くまでの道」で、それをライブラリの中に隠したら 節が成立しません。それに、必要な部品は 3 つだけで、DOM は自分で書けます。

<input type="range">/<input type="color">/<input type="checkbox">を、ラベルと組にして生やす関数を 1 つずつ書きます。1 つの関数に型で分岐させないのが要点で、そうすると引数が「どの型のときに何が効くか」の表になって、かえって読みにくくなります。

src/lessons/36-interaction-and-output/gui.ts(抜粋)
export function addSlider(parent: HTMLElement, options: SliderOptions): void {
  const { row, id } = createRow(parent, options.label);
  const input = document.createElement('input');
  input.type = 'range';
  input.id = id;
  input.min = String(options.min);
  input.max = String(options.max);
  input.step = String(options.step);
  input.value = String(options.value);

input と change

スライダーで使うのはinputイベントです。changeはドラッグを離したときにしか来ないので、動かしながら結果を見ることができません。 チェックボックスは押した瞬間に確定するのでchangeで足ります(inputも同時に来ます)。

src/lessons/36-interaction-and-output/gui.ts(抜粋)
input.addEventListener('input', () => {
    const value = Number(input.value);
    output.textContent = value.toFixed(digits);
    options.onInput(value);
  });

options.onInput(value)が呼ぶ先を見てください。uniform を書いていません。

src/lessons/36-interaction-and-output/main.ts(抜粋)
addColorPicker(guiPanel, {
  label: '基本色',
  value: params.colorHex,
  onInput: (hex) => {
    params.colorHex = hex;
    hexToLinear(hex, params.baseColor);
  },
});

やっているのは状態オブジェクトの更新だけで、その状態を読んでuniform3fvを呼ぶのは次のフレームの描画コードです。1 節の結論とまったく同じ形です。イベントで直接 uniform を書くと、(a) その時点でどのプログラムがuseProgramされているかに依存し、(b) 1 フレームに 10 回ドラッグイベントが来れば 10 回uniform3fvを呼ぶことになります。どちらもフレームで読めば消えます。

色は sRGB エンコード値で返ってくる

<input type="color">が返すのは"#rrggbb"の文字列です。これは画面に出る sRGB エンコード値であって、リニア値ではありません(第27章 1 節)。この章のシーンはリニア空間で計算しているので、入口で 1 回だけ変換します。

src/lessons/36-interaction-and-output/main.ts(抜粋)
function srgbToLinear(c: number): number {
  return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
}

function hexToLinear(hex: string, out: vec3): vec3 {
  const value = Number.parseInt(hex.slice(1), 16);
  out[0] = srgbToLinear(((value >> 16) & 255) / 255);
  out[1] = srgbToLinear(((value >> 8) & 255) / 255);
  out[2] = srgbToLinear((value & 255) / 255);
  return out;
}

式はcolor.glslのsrgbToLinearと同じもの(ES 3.0.6 §3.8.16 式 3.26)を TypeScript に書き写しただけです。デモ 3 の読み出し行には、選んだ#rrggbbと、変換後のリニア値の両方を出しています —#808080を選ぶとリニアは0.216前後で、0.5 にはならないことが見えます(第27章 1 節の看板そのものです)。

見た目とアクセシビリティ

スタイルはこのページの scoped<style>に置いています(src/styles/global.cssは触っていません)。既存の.demo-controlsと同じ見た目に揃え、色は CSS 変数(--accent/--bg-subtle/--border/--text-muted)で書いているので、ライトでもダークでも読めます。ひとつ注意が要るのは、JS が生やした要素には Astro のスコープ属性が付かないことです。だから.demo-gui :global(.gui-row)のように、テンプレートに書いてある入れ物を起点にして:global()で中身を指しています。

ラベルと入力は<label for>とidで結んでいます。これでラベルを押しても部品が動きますし、この関連づけがそのまま支援技術に読まれる名前になります。見えるラベルがあるのでaria-labelは重ねていません(重ねると読まれる名前がラベルと別物になりえます)。aria-labelが要るのは、見えるラベルを持てない部品のほうです。<input type="range">は矢印キーで操作できるので、キーボード対応のために足すものはありません。

ひとつ知っておくべき副作用があります。値の表示に使っている<output>は暗黙のロールがstatusで(W3C「ARIA in HTML」の要素表)、statusは暗黙の aria-live が politeです(WAI-ARIA 1.2)。つまりスライダーを動かすあいだ、支援技術が数値を読み上げ続けうるということで、<input type="range">自身も値を通知するので二重になります。この章はそのままにしていますが、読み上げを片方に寄せたいなら<span aria-hidden="true">にするか、<output>のままロールを上書きしてください。実機のスクリーンリーダーでは確認していません(仕様の上でどうなるか、までです)。

6. 画面を書き出す — toBlob と「描いた直後」という条件

第2章 2 節のpreserveDrawingBufferの説明は「軌跡を残す表現やスクリーンショットには別の手を使う(第29章・第36章)」で 終わっていました。軌跡のほうは第29章 5 節が引き取ったので、残っているのがこちらです。

既定のpreserveDrawingBuffer: falseでは、合成のあとに描画バッファがクリアされます(解説の本体は第13章 4 節のaside.pitfall)。WebGL 1.0 仕様はこの属性の説明に、書き出しに直接効く一文を 置いています —「このフラグが false の場合、レンダリング関数が戻ったあとにこのコンテキストをソース画像として 使う操作は、未定義の挙動につながりうる。これにはreadPixelsやtoDataURLの呼び出し、このコンテキストの canvas を別のコンテキストのtexImage2DやdrawImageのソース画像として使うこと、この canvas からImageBitmapを作ることが含まれる」。

正しい手は 3 つです。

  1. rAF コールバックの中で、描いた直後に呼ぶ。この章が選んだ道です。 同じタスクの中なので合成はまだ起きていません
  2. preserveDrawingBuffer: trueにする。属性の意味は第2章 2 節。仕様自身が「一部のハードウェアでは大きな性能上の影響がありうる」と 書いていて、しかもそのコストは書き出さないフレームにも常に乗ります。 前フレームを残したいだけなら FBO のほうが素直です(第29章 5 節)
  3. FBO に描いて readPixels する。合成の対象ではないので、 いつ読んでもかまいません。次の 7 節がこれです

toBlob と toDataURL

HTML 仕様(WHATWG Living Standard)§4.12.5 が 2 つのメソッドの手順を定めています。toDataURL(type, quality)は同期で、「このビットマップをファイルとして直列化し、それを表すdata:URL を返す」。toBlob(callback, type, quality)は 3 ステップで、

  1. resultにこのビットマップのコピーを取る
  2. in parallel に(= 別スレッドで)resultをファイルとして直列化する
  3. タスクをキューに積み、Blob を作ってコールバックを呼ぶ

ビットマップの複製は 1 の時点、つまり呼んだ瞬間に作られます。非同期なのは PNG への符号化だけです。だから「rAF の中で描いた直後にtoBlobを呼ぶ」で正しく、コールバックが後から来ることは問題になりません。

2 つの違いは、同期か非同期かと、大きさです。toDataURLが返すのは base64 のdata:URL なので、符号化後のバイト列が 4/3 倍(約 33% 増)の文字列になり、それが JS のヒープに乗ります。7 節の 4 倍書き出しのような大きさになると、これは無視できません。 この章はtoBlobだけを使っています。

src/lessons/36-interaction-and-output/capture.ts(抜粋)
export function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob | null> {
  return new Promise((resolve) => {
    // toBlob は非同期だが、**ビットマップの複製は呼んだ時点で作られる**
    // (HTML 仕様 §4.12.5: "set result to a copy of this canvas element's bitmap" までは
    //  同期で、PNG への直列化だけが in parallel)。だから rAF の中で描いた直後に
    // 呼べば、あとで PNG になるのは「そのときの絵」で正しい
    canvas.toBlob((blob) => resolve(blob), 'image/png');
  });
}
src/lessons/36-interaction-and-output/main.ts(抜粋)
// --- 書き出し(6〜7 節) ------------------------------------------------------------
    // **描いた直後・同じタスクの中**でしか canvas.toBlob は正しい絵を返さない。
    // だから書き出しの処理はここ、rAF コールバックの末尾に置いてある
    if (captureRequested && captureScale === 1) {
      captureRequested = false;
      const width = gl.drawingBufferWidth;
      const height = gl.drawingBufferHeight;
      void canvasToBlob(canvas).then((blob) => {
        publish(blob, width, height, 1, 'canvas.toBlob(6 節)');
      });
    } else if (captureRequested) {

アルファをどう扱うか

PNG はアルファを持てるので、alpha: trueの canvas をそのまま PNG にすると背景が透けます。しかもここに変換が 1 段入ります。WebGL 1.0 仕様の "Premultiplied Alpha, Canvas APIs and texImage2D" の節が こう定めています —「WebGL の内容が描かれている Canvas に対してtoDataURLが呼ばれたとき、要求された画像形式がアルファ乗算済みを指定しておらず、かつ WebGL コンテキストのpremultipliedAlphaが true であるなら、画素値は非乗算に戻さなければならない。すなわちカラーチャンネルを アルファチャンネルで割る。この操作は不可逆であることに注意」。HTML 仕様側も §4.12.5.7 で「乗算済みと非乗算のあいだの変換は、完全不透明でない色に対しては 不可逆な操作である」と書いています。

この章の canvas はコンテキスト属性を既定のまま使っています(alpha: true/premultipliedAlpha: true。第2章 2 節)。そのうえでシーンは毎フレーム clearColor(0.06, 0.07, 0.09, 1) で全面を塗り、 出力するフラグメントのアルファも常に 1です。つまり書き出しを行うデモ 4 の絵は全画素が完全不透明で、 上の割り算はどの画素でも「1 で割る」になります。不可逆な変換は実際には起きません。 例外はデモ 1 の可視化のほうで、あちらはoverlay.fragが輪の縁でアルファ 1 未満のフラグメントを出します — デモ 1 の canvas をそのまま書き出せば、その画素では上の割り算が実際に効きます(デモ 1 に書き出しのボタンは付けていません)。半透明を含む絵を書き出したいときだけ、ここが問題になります。

保存はユーザーが押してから

BlobができたらURL.createObjectURLで URL を作り、<a download>に付けます。この章は自動でクリックしません。読者が「保存」を押して初めて ファイルになります(検証のたびにダウンロードフォルダが増えるのは、教材としては邪魔なので)。

src/lessons/36-interaction-and-output/main.ts(抜粋)
releaseSaveUrl();
    const url = URL.createObjectURL(blob);
    saveUrl = url;
    if (saveLink) {
      saveLink.href = url;
      saveLink.download = `webgl2-36-${width}x${height}.png`;
      saveLink.textContent = `保存 (${width}×${height} PNG)`;
      saveLink.hidden = false;
    }

createObjectURLで作った URL は、明示的に解放しないとドキュメントが生きているあいだBlobを掴んだままになります。4 倍書き出しを何度も試すと、そのぶんのメモリが積み上がります。 次の書き出しの前と、デモを離れるときに手放します。

src/lessons/36-interaction-and-output/main.ts(抜粋)
function releaseSaveUrl(): void {
    if (saveUrl !== null) {
      // createObjectURL で作った URL は、明示的に解放しないとドキュメントが
      // 生きているあいだ Blob を掴んだままになる(6 節)
      URL.revokeObjectURL(saveUrl);
      saveUrl = null;
    }
    if (saveLink) saveLink.hidden = true;
  }

7. 画面より大きく書き出す — FBO とタイル分割

印刷したい、あるいは縮小して細部を締めたい。理由は何であれ、画面より大きい絵が欲しくなります。 素朴な手は「canvas を巨大にする」ですが、これはすぐ上限に当たります。ES 3.0.6 Table 6.28 の最小要求値は、MAX_TEXTURE_SIZEが2048、MAX_RENDERBUFFER_SIZEも2048。MAX_VIEWPORT_DIMSには数値の最小要求値すら無く、§2.13.1 が「最大のビューポート寸法は、 (ディスプレイがあるなら)描画対象のディスプレイの可視寸法と、フレームバッファオブジェクトに アタッチして作成できる最大のレンダーバッファ画像との、大きいほうの値以上でなければならない」と 条件で書いているだけです。実装が実際にいくつを返すかは環境しだいで、 「2048 は超えているはずだ」という期待でコードを書くと、当たる環境で当たります。

だから分けて描いて、貼り合わせます。FBO は 1 枚(画面と同じ大きさ)だけ作り、 タイルごとに描いてはreadPixelsで読み出して、CPU 側の 1 枚の配列に詰めていきます。

倍率仕上がり(px)画素数タイル数中間バッファ最小要求値 2048 を超えるか
等倍1,044 × 696726,6241(FBO を使わない)—(toBlob が内部で持つ)超えない
2 倍2,088 × 1,3922,906,496411,625,984 B(11.1 MiB)超える
4 倍4,176 × 2,78411,625,9841646,503,936 B(44.3 MiB)超える

書き出しの倍率と、その内訳。前提: 描画バッファ 1,044 × 696(dpr 1.5 × CSS 696 × 464 の場合の一例。あなたの環境での実際の値は読み出し行に出ます)。 「中間バッファ」は貼り合わせ先の RGBA8 配列 1 本で、2D canvas と PNG は別に要る。再現スクリプト:scratchpad/p5b/ch36-counts.mjs

表の右端を見てください。この一例では、2 倍の時点でもう 2,048 を超えます。描画バッファがもっと狭ければ超えるのは 4 倍からになりますが(下の aside の実測は描画バッファ 694 × 462 で、2 倍が 1,388 px・4 倍が 2,776 px)、いずれにせよ 1 枚の巨大なテクスチャで作る手が使えなくなるのは、4 倍のような極端な場合だけではありません。あなたの環境の値は読み出し行に出ます。

視錐台を切る — perspective は frustum の特別な場合

タイルごとに変えるのは射影行列だけです。第12章 4 節でmat4.perspective(fovy, aspect, near, far)を使いましたが、これはmat4.frustum(left, right, bottom, top, near, far)の左右上下が対称な特別な場合にすぎません。fovyは「ニア平面の高さ」を角度で言い換えたものなので、

top = near · tan(fovy / 2)、right = top · aspect

としてfrustum(−right, right, −top, top, near, far)と書けば、まったく同じ行列になります。これは実際に確かめました— 上の式で作ったfrustumとmat4.perspective(45°, 1044/696, 0.1, 100)の 16 要素の最大差は0です(再現スクリプトscratchpad/p5b/ch36-tile-check.mjs)。

ここまで来れば、タイルは[−right, right] × [−top, top]という長方形を格子に切った 1 区画です。

src/lessons/36-interaction-and-output/capture.ts(抜粋)
export function tileProjection(
  out: mat4,
  fovy: number,
  aspect: number,
  near: number,
  far: number,
  tilesX: number,
  tilesY: number,
  col: number,
  row: number,
): mat4 {
  const top = near * Math.tan(fovy / 2);
  const right = top * aspect;
  return mat4.frustum(
    out,
    -right + ((2 * right) / tilesX) * col,
    -right + ((2 * right) / tilesX) * (col + 1),
    -top + ((2 * top) / tilesY) * row,
    -top + ((2 * top) / tilesY) * (row + 1),
    near,
    far,
  );
}

aspectに渡すのは仕上がり全体の縦横比で、タイル 1 枚の縦横比ではありません。ここを取り違えると、絵が縦横に伸びたタイルの寄せ集めになります。

切り出しが正しいことも数値で確かめました。ビュー空間にランダムな点を 20 万個撒き、 全体の射影で画面内(NDC が[−1, 1]²の内側)に入った75,693 点について、「全体の NDC から決まるタイル」の射影で同じ点を落とし直したところ、そのタイルの NDC からはみ出した点は 0 点、画像全体に戻したときの位置の最大誤差は画像幅の 3.0 × 10⁻⁸でした(4 × 4 タイル・fovy45°・near0.1・far100・aspect1044/696)。撒き方: ビュー空間のx ∈ [−12, 12]/y ∈ [−8, 8]/z ∈ [−20.5, −0.5]に一様、乱数は線形合同法(seed = 12345、a = 1664525、c = 1013904223、法2³²)。分母の 75,693 と最大誤差の値は、この撒き方に依存します— 範囲や乱数を変えれば変わりますが、「はみ出し 0 点」と「誤差は 10⁻⁸ の桁」は変わりません。

readPixels — 読める組み合わせと、上下

ES 3.0.6 §4.3.2 は、readPixelsのformat/typeの組み合わせについて「たいていの場合 2 つの組み合わせだけが受け付けられる」と書いています。

  1. レンダリング面のフォーマットで決まるもの。正規化固定小数点のレンダリング面ならRGBA+UNSIGNED_BYTE(符号付き整数ならRGBA_INTEGER+INT、符号なし整数ならRGBA_INTEGER+UNSIGNED_INT)
  2. 実装が選んだもの。IMPLEMENTATION_COLOR_READ_FORMAT/IMPLEMENTATION_COLOR_READ_TYPEを問い合わせて知る

この章の FBO はRGBA8(正規化固定小数点)なので、1 番目のRGBA+UNSIGNED_BYTEがそのまま使えます。float の FBO(第20章 8 節・第33章 2 節)から読むときは、 この組み合わせが使える保証がありません— 問い合わせるか、いったんRGBA8に落としてから読むことになります。

もうひとつ、readPixelsは左下原点で読みます(WebGL の座標系がそうだからです)。PNG もImageDataも上の行が先なので、どこかで行の順を逆にしなければなりません。この章は貼り付けるときに逆順で置いて、あとから全面をひっくり返す処理を無くしています。

src/lessons/36-interaction-and-output/capture.ts(抜粋)
// PACK_ALIGNMENT の既定は 4(ES 3.0.6 Table 4.4)。RGBA / UNSIGNED_BYTE は
  // 1 画素 4 バイトなので、幅が何であっても行の先頭は 4 の倍数に乗る = 詰め物は入らない
  gl.readPixels(0, 0, job.tileWidth, job.tileHeight, gl.RGBA, gl.UNSIGNED_BYTE, job.scratch);
  bindRenderTarget(gl, null);

  // readPixels は**左下原点**、ImageData は上が先。ここで行の並びを逆にしておけば、
  // あとでもう一度全面をひっくり返す必要がない(7 節)
  const rowBytes = job.tileWidth * 4;
  const destRowBytes = job.width * 4;
  for (let y = 0; y < job.tileHeight; y++) {
    const destY = job.height - (row * job.tileHeight + y) - 1;
    const destAt = destY * destRowBytes + col * rowBytes;
    job.pixels.set(job.scratch.subarray(y * rowBytes, (y + 1) * rowBytes), destAt);
  }

readPixels は同期で、パイプラインを止める

WebGL 2.0 仕様はreadPixelsの項に注意書きを置いています —「これはブロックする操作である。結果を返すには、ソースのフレームバッファへの それまでのレンダリング操作をすべて完了させなければならないからだ。マルチプロセスの WebGL 実装では、リモートプロセスから結果を取ってくるための高価なプロセス間往復も発生する」。書き出しのような 1 回きりの処理なら払える代金ですが、毎フレームやってはいけません。

非同期に読む道はあります。同じ注意書きが続けて「代わりにPIXEL_PACK_BUFFERへreadPixelsすることを検討し、getBufferSubDataでそのバッファからデータを読むこと」と勧めていて、getBufferSubDataの項には「ソースバッファへの書き込みのあとにfenceSyncを差し込み、それが通過するのを待ってからgetBufferSubDataを行う」という手順が書いてあります。この章では実装していません。 書き出しは 1 回きりの操作で、ブロックしてもユーザーが困らないからです。

1 フレームに 1 枚ずつ

4 × 4 = 16 枚を 1 フレームで回すと、そのあいだページは固まります(readPixelsが 16 回ブロックするので、なおさらです)。この章は1 フレームに 1 枚だけ進め、 進み具合を読み出し行に出しています。

src/lessons/36-interaction-and-output/main.ts(抜粋)
if (job) {
      const current = job;
      const done = captureNextTile(gl, current, FOVY, NEAR, FAR, (tileProjectionMatrix) => {
        drawScene(tileProjectionMatrix, time);
      });
      captureNote = `書き出し中… タイル ${current.rendered} / ${current.total}`;

全部揃ったら、ImageDataにして 2D canvas へputImageDataし、その canvas のtoBlobで PNG にします。WebGL から直接 PNG は作れないので、この 2D canvas は避けられません。2D コンテキストなので「WebGL コンテキストは 1 ページ 1〜2 個まで」の規約には 当たりませんが、数十 MB のビットマップを抱えるので、使い終わったら0 × 0にして手放しています。FBO もdeleteTileCaptureで解放します(解放の一般論は第35章 7 節)。

これで第2章の伏線が回収できました —preserveDrawingBufferを true にせずに、画面のスクリーンショットも、画面より大きい絵も書き出せます。前者は「描いた直後に呼ぶ」で、後者は「そもそも合成されない FBO に描く」で。

8. ここから先 — WebGPU と、この本が扱わなかったもの

第1章で「WebGPU への道筋は最終章で」と書き、第32章では「WebGL2 に compute shader は無い」で止めました。ここで両方を回収します。

WebGPU の位置づけ(出典と日付)

WebGPU は W3C の GPU for the Web Working Group が策定している API です。2026 年 8 月 8 日時点で、WebGPUは W3C Candidate Recommendation Draft(2026 年 7 月 14 日版)、シェーディング言語のWGSL(WebGPU Shading Language)も Candidate Recommendation Draft(2026 年 8 月 6 日版)です。Recommendation(勧告)にはまだ達していません。

実装のほうは、ベンダーの告知で日付が追えます。

ここに書いたのは「この日付にこう告知された」という事実だけです。あなたが これを読んでいる時点で、あなたの環境の対応がどうなっているかはnavigator.gpuの有無で確かめてください。

構造の違い

WebGL2(この本)WebGPU
状態機械。bindBuffer/useProgram/enableでグローバルな状態を書き換え、ドローはそのときの状態で走る(第1章 4 節)パイプラインステートオブジェクト。シェーダー・頂点レイアウト・ ブレンド・深度・カリングをひとかたまりにしてあらかじめ作っておき、 描くときに 1 個選ぶ。「いまどの状態か」を追う必要が消える代わりに、組み合わせの数だけ オブジェクトを作ることになる
暗黙の同期。gl.*を呼んだ順に効いたように見える。実際にいつ GPU が動くかはドライバ任せで、readPixelsのような同期点で初めて待たされる(7 節)明示的なコマンドエンコーダ。コマンドをバッファに記録して、 キューに submit する。いつ何が投入されるかがコードに書いてある
GLSL ES 3.00。文字列を実行時にコンパイルし、失敗したらログを読む(第3章)WGSL。別の言語。型の書き方も入出力の宣言も違うので、シェーダーは書き直しになります。ただし式の中身 (ベクトル演算・行列・組み込み関数)はほとんどそのまま移せる
compute shader が無い。GPGPU はテクスチャ方式(第29章)か transform feedback(第32章)で、どちらも描画パイプラインの一部を借りているcompute shader がある。第29章の ping-pong も第32章の粒子更新も、 「配列を読んで配列に書く」という素直な形で書ける。ラスタライザを止めるRASTERIZER_DISCARDのような迂回が要らない
テクスチャユニット + uniform。uniform1iでユニット番号を渡し(第16章 6 節)、値はuniform*fvで 1 本ずつ。まとめて送る仕組みが UBO(第34章 3 節)バインドグループ。テクスチャ・サンプラ・バッファをひとかたまりに まとめて、番号で差し替える。第34章が「更新頻度で層を分ける」と言っていたことが、 API の側に用意されている

WebGPU に行っても変わらないもの

ここがこの節の要点です。上の表で変わるのは、ほとんどが「API の形」です。 この本で時間をかけた部分は、そのまま持っていけます。

この本が扱わなかったもの

36 章を使っても、WebGL とリアルタイム CG の全体からすればごく一部しか触れていません。名前だけ出して素通りしたものと、一度も出てこなかったものを、 正直に並べます。

次に何をするか

いちばん確実なのは、この本のデモを 1 本選んで、自分の作品として作り直すことだと 思います。各章のsrc/lessons/はそのために置いてあります。上の「扱わなかったもの」の一覧は、そのときの手がかりにもなります — たとえば「テキストを出したい」と思ったら、第24章の SDF と第16章のテクスチャで アトラスを読む道が見えるはずです。仕様を引く癖もついているはずなので、registry.khronos.orgとhtml.spec.whatwg.orgは開いておいてください。

コード全文

src/lessons/36-interaction-and-output/main.ts
// 第36章: インタラクションと出力 — ポインタ・オーディオ反応・書き出し
//
// この章は WebGL の外側の道具を 4 つ足す。シーンは 4 本のデモで共通で、
// 変わるのは「何がそのシーンを動かすか」だけ:
//   1 入力   input.ts   ポインタ・ピンチ・キーを 1 本にまとめてカメラへ(1〜3 節)
//   2 音     audio.ts   自前の音源の FFT を R8 テクスチャで GPU へ(4 節)
//   3 つまみ gui.ts     自作の GUI からシーンのパラメータへ(5 節)
//   4 書き出し capture.ts toBlob と、FBO タイル分割(6〜7 節)
//
// 4 つに共通する設計がひとつある ―― **イベントは状態を更新するだけで、
// 絵は 1 フレームに 1 回、その状態を読んで描く**。入力も GUI も同じ形をしている。
//
// 第34章のミニエンジンは使っていない。この章のコードは
// src/lib/shader.ts / geometry.ts / framebuffer.ts と、この章のファイルだけで閉じている。

import { mat3, mat4, type ReadonlyVec3, vec3 } from 'gl-matrix';
import { createSphere, createTorus, type Geometry, interleave } from '../../lib/geometry';
import { compileShader, linkProgram } from '../../lib/shader';
import { createAudio } from './audio';
import {
  beginTileCapture,
  canvasToBlob,
  captureNextTile,
  deleteTileCapture,
  finishTileCapture,
  type TileCapture,
} from './capture';
import colorChunkSource from './color.glsl?raw';
import { addColorPicker, addSlider, addToggle, clearGui } from './gui';
import { createInput } from './input';
import overlayFragmentTemplate from './overlay.frag?raw';
import overlayVertexSource from './overlay.vert?raw';
import sceneFragmentTemplate from './scene.frag?raw';
import sceneVertexSource from './scene.vert?raw';

// #include の解決は第23章からの文字列置換。置換文字列を関数で渡しているのは、
// String.replace が `$&` などを特別扱いするため
function resolveIncludes(source: string): string {
  return source.replace('#include "color.glsl"', () => colorChunkSource.trim());
}

// ---------------------------------------------------------------------------
// 定数
// ---------------------------------------------------------------------------

// カメラ定数(第3部の標準): fovy 45°・near 0.1・far 100
const FOVY = (45 * Math.PI) / 180;
const NEAR = 0.1;
const FAR = 100;

const UP: ReadonlyVec3 = vec3.fromValues(0, 1, 0);
// canvas の CSS 背景色と同じ
const BACKGROUND_COLOR: ReadonlyVec3 = vec3.fromValues(0.06, 0.07, 0.09);

const FLOAT_BYTES = Float32Array.BYTES_PER_ELEMENT;

// 描画バッファの上限を dpr 1.5 に抑える。理由は 7 節のタイル書き出しで、
// 仕上がりが描画バッファの 4 倍(面積で 16 倍)まで伸びるため。
// dpr 2 のままだと 4 倍書き出しの中間バッファだけで 80 MB を超える。
// 解像度スケールの一般論は第35章 5 節
const MAX_DPR = 1.5;

// 面から光源へ向かう単位ベクトル(第17章の約束)
const LIGHT_DIRECTION: ReadonlyVec3 = vec3.normalize(
  vec3.create(),
  vec3.fromValues(0.4, 0.8, 0.45),
);
const LIGHT_COLOR: ReadonlyVec3 = vec3.fromValues(1.0, 0.96, 0.9);

const PHI_LIMIT = Math.PI / 2 - 0.05;
const MIN_RADIUS = 2.0;
const MAX_RADIUS = 14;

/** キー操作でターゲットが動く速さ(ワールド単位/秒) */
const KEY_MOVE_SPEED = 1.6;
/** キー操作で半径が変わる速さ(1 秒あたりの対数) */
const KEY_ZOOM_SPEED = 1.2;

/** 波形の「無音の位置」。b[k] = floor(128 (1 + x[k])) なので x = 0 で 128(4 節) */
const WAVEFORM_CENTER = 128 / 255;

/** 音でトーラスを押し出す量(ワールド単位)。0〜1 の値に掛かる */
const AUDIO_DISPLACEMENT = 0.45;

const DEMO_IDS = ['input', 'audio', 'gui', 'capture'] as const;
type DemoId = (typeof DEMO_IDS)[number];

const DEMO_LABELS: Record<DemoId, string> = {
  input: '入力',
  audio: '音に反応',
  gui: 'つまみ',
  capture: '書き出す',
};

// ---------------------------------------------------------------------------
// 色: sRGB エンコード値 → リニア値(第27章 2 節)
// ---------------------------------------------------------------------------

// color.glsl の srgbToLinear と同じ式(ES 3.0.6 §3.8.16 式 3.26)を CPU に書き写したもの。
// <input type="color"> が返す "#rrggbb" は sRGB エンコード値なので、
// リニア空間で計算するシーンへ渡す前にここを通す(5 節)
function srgbToLinear(c: number): number {
  return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
}

function hexToLinear(hex: string, out: vec3): vec3 {
  const value = Number.parseInt(hex.slice(1), 16);
  out[0] = srgbToLinear(((value >> 16) & 255) / 255);
  out[1] = srgbToLinear(((value >> 8) & 255) / 255);
  out[2] = srgbToLinear((value & 255) / 255);
  return out;
}

// ---------------------------------------------------------------------------
// メッシュ
// ---------------------------------------------------------------------------

interface Mesh {
  vao: WebGLVertexArrayObject;
  indexCount: number;
}

/** interleave() の [pos3, normal3, uv2] をそのまま VAO に配線する(第14章) */
function createMesh(gl: WebGL2RenderingContext, geometry: Geometry): Mesh {
  const vao = gl.createVertexArray();
  gl.bindVertexArray(vao);

  const vbo = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
  gl.bufferData(gl.ARRAY_BUFFER, interleave(geometry), gl.STATIC_DRAW);

  const stride = 8 * FLOAT_BYTES;
  gl.enableVertexAttribArray(0); // a_position
  gl.vertexAttribPointer(0, 3, gl.FLOAT, false, stride, 0);
  gl.enableVertexAttribArray(1); // a_normal
  gl.vertexAttribPointer(1, 3, gl.FLOAT, false, stride, 3 * FLOAT_BYTES);
  gl.enableVertexAttribArray(2); // a_uv
  gl.vertexAttribPointer(2, 2, gl.FLOAT, false, stride, 6 * FLOAT_BYTES);

  const ibo = gl.createBuffer();
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo); // IBO のバインドは VAO に記録される(第13章)
  gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, geometry.indices, gl.STATIC_DRAW);

  gl.bindVertexArray(null);
  gl.bindBuffer(gl.ARRAY_BUFFER, null);
  return { vao, indexCount: geometry.indices.length };
}

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

function setup(
  gl: WebGL2RenderingContext,
  canvas: HTMLCanvasElement,
  controls: HTMLParagraphElement,
  options: HTMLParagraphElement,
  captureControls: HTMLParagraphElement,
  guiPanel: HTMLDivElement,
  readout: HTMLParagraphElement,
): void {
  // --- プログラム ---------------------------------------------------------------
  const sceneProgram = linkProgram(
    gl,
    compileShader(gl, gl.VERTEX_SHADER, sceneVertexSource),
    compileShader(gl, gl.FRAGMENT_SHADER, resolveIncludes(sceneFragmentTemplate)),
  );
  const overlayProgram = linkProgram(
    gl,
    compileShader(gl, gl.VERTEX_SHADER, overlayVertexSource),
    compileShader(gl, gl.FRAGMENT_SHADER, resolveIncludes(overlayFragmentTemplate)),
  );

  const sceneLocations = {
    model: gl.getUniformLocation(sceneProgram, 'u_model'),
    view: gl.getUniformLocation(sceneProgram, 'u_view'),
    projection: gl.getUniformLocation(sceneProgram, 'u_projection'),
    normalMatrix: gl.getUniformLocation(sceneProgram, 'u_normalMatrix'),
    audio: gl.getUniformLocation(sceneProgram, 'u_audio'),
    audioAmount: gl.getUniformLocation(sceneProgram, 'u_audioAmount'),
    audioRow: gl.getUniformLocation(sceneProgram, 'u_audioRow'),
    audioBias: gl.getUniformLocation(sceneProgram, 'u_audioBias'),
    audioTint: gl.getUniformLocation(sceneProgram, 'u_audioTint'),
    lightDirection: gl.getUniformLocation(sceneProgram, 'u_lightDirection'),
    lightColor: gl.getUniformLocation(sceneProgram, 'u_lightColor'),
    cameraPosition: gl.getUniformLocation(sceneProgram, 'u_cameraPosition'),
    baseColor: gl.getUniformLocation(sceneProgram, 'u_baseColor'),
    shininess: gl.getUniformLocation(sceneProgram, 'u_shininess'),
    ambient: gl.getUniformLocation(sceneProgram, 'u_ambient'),
  };

  // --- メッシュ -------------------------------------------------------------------
  const torus = createMesh(gl, createTorus(1.15, 0.34, 96, 32));
  const sphere = createMesh(gl, createSphere(0.5, 32, 16));

  // --- 音(4 節) -----------------------------------------------------------------
  // AudioContext はここでは作らない。作るのは最初に「再生」が押されたとき
  const audio = createAudio();
  const BIN_COUNT = audio.binCount;
  const frequencyData = new Uint8Array(BIN_COUNT);
  const waveformData = new Uint8Array(audio.fftSize);
  /** 1 フレームにアプリが WebGL へ渡す音のバイト数。2 行ぶん */
  const AUDIO_BYTES_PER_FRAME = BIN_COUNT * 2;

  // 幅 = ビン数・高さ 2 の R8。行 0 がスペクトル、行 1 が波形
  const audioTexture = gl.createTexture();
  gl.activeTexture(gl.TEXTURE0);
  gl.bindTexture(gl.TEXTURE_2D, audioTexture);
  gl.texStorage2D(gl.TEXTURE_2D, 1, gl.R8, BIN_COUNT, 2);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
  // 1 行が 1 バイト × 幅。既定の 4 だと、幅が 4 の倍数でないとき行の末尾に
  // 詰め物が入る前提で読まれる(ES 3.0.6 §3.7.2.1 式 3.13)。
  // ここは幅 1024 なのでたまたま 4 の倍数だが、幅を変えた瞬間に壊れるので 1 にしておく
  gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
  gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, BIN_COUNT, 1, gl.RED, gl.UNSIGNED_BYTE, frequencyData);
  waveformData.fill(128);
  gl.texSubImage2D(
    gl.TEXTURE_2D,
    0,
    0,
    1,
    BIN_COUNT,
    1,
    gl.RED,
    gl.UNSIGNED_BYTE,
    waveformData.subarray(audio.fftSize - BIN_COUNT),
  );

  // --- シーンのパラメータ(5 節の GUI が書き換える) ---------------------------------
  const params = {
    colorHex: '#4da3ff',
    baseColor: vec3.fromValues(0, 0, 0),
    shininess: 48,
    spin: 0.35,
    ambient: 1.0,
    showSphere: true,
    showOverlay: true,
  };
  hexToLinear(params.colorHex, params.baseColor);

  // --- 入力(1〜3 節) --------------------------------------------------------------
  const input = createInput(canvas);
  const orbit = { theta: 0.6, phi: 0.3, radius: 4.4 };
  const target = vec3.fromValues(0, 0, 0);

  // --- 状態 -------------------------------------------------------------------------
  let demoId: DemoId = 'input';
  let audioView: 'spectrum' | 'waveform' = 'spectrum';
  let captureScale = 1;
  let job: TileCapture | null = null;
  let captureRequested = false;
  let saveUrl: string | null = null;
  let captureNote = 'まだ書き出していません';
  let bassLevel = 0;

  // --- 可視化用のバッファ(デモ 1) ---------------------------------------------------
  // 1 点 = [x, y, size, r, g, b]。ポインタは多くても数本なので 32 点で足りる
  const OVERLAY_FLOATS = 6;
  const OVERLAY_CAPACITY = 32;
  const overlayData = new Float32Array(OVERLAY_CAPACITY * OVERLAY_FLOATS);
  const overlayVao = gl.createVertexArray();
  gl.bindVertexArray(overlayVao);
  const overlayBuffer = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, overlayBuffer);
  gl.bufferData(gl.ARRAY_BUFFER, overlayData.byteLength, gl.DYNAMIC_DRAW);
  const overlayStride = OVERLAY_FLOATS * FLOAT_BYTES;
  gl.enableVertexAttribArray(0);
  gl.vertexAttribPointer(0, 2, gl.FLOAT, false, overlayStride, 0);
  gl.enableVertexAttribArray(1);
  gl.vertexAttribPointer(1, 1, gl.FLOAT, false, overlayStride, 2 * FLOAT_BYTES);
  gl.enableVertexAttribArray(2);
  gl.vertexAttribPointer(2, 3, gl.FLOAT, false, overlayStride, 3 * FLOAT_BYTES);
  gl.bindVertexArray(null);
  gl.bindBuffer(gl.ARRAY_BUFFER, null);

  // --- 毎フレーム使い回す入れ物 -------------------------------------------------------
  const eye = vec3.create();
  const view = mat4.create();
  const projection = mat4.create();
  const model = mat4.create();
  const normalMatrix = mat3.create();
  const cameraRight = vec3.create();
  const cameraForward = vec3.create();

  // ---------------------------------------------------------------------------
  // ボタン
  // ---------------------------------------------------------------------------

  /** aria-pressed で選択状態を持つボタンの並びを作る(第30章の option-buttons と同じ形) */
  function createButtonGroup(
    parent: HTMLElement,
    labels: string[],
    selected: number,
    onSelect: (index: number) => void,
  ): void {
    const buttons: HTMLButtonElement[] = [];
    labels.forEach((label, index) => {
      const button = document.createElement('button');
      button.type = 'button';
      button.textContent = label;
      button.setAttribute('aria-pressed', index === selected ? 'true' : 'false');
      button.addEventListener('click', () => {
        for (const other of buttons) other.setAttribute('aria-pressed', 'false');
        button.setAttribute('aria-pressed', 'true');
        onSelect(index);
      });
      buttons.push(button);
      parent.append(button);
    });
  }

  /** 押しても選択状態にならない、ただの動作ボタン */
  function createActionButton(parent: HTMLElement, label: string): HTMLButtonElement {
    const button = document.createElement('button');
    button.type = 'button';
    button.textContent = label;
    parent.append(button);
    return button;
  }

  let captureButton: HTMLButtonElement | null = null;
  let saveLink: HTMLAnchorElement | null = null;

  function releaseSaveUrl(): void {
    if (saveUrl !== null) {
      // createObjectURL で作った URL は、明示的に解放しないとドキュメントが
      // 生きているあいだ Blob を掴んだままになる(6 節)
      URL.revokeObjectURL(saveUrl);
      saveUrl = null;
    }
    if (saveLink) saveLink.hidden = true;
  }

  function cancelCapture(): void {
    if (job) {
      deleteTileCapture(gl, job);
      job = null;
    }
    captureRequested = false;
    if (captureButton) captureButton.disabled = false;
  }

  function buildOptions(): void {
    options.replaceChildren();
    captureControls.replaceChildren();
    clearGui(guiPanel);
    captureButton = null;
    saveLink = null;
    options.hidden = false;
    captureControls.hidden = true;
    guiPanel.hidden = true;

    if (demoId === 'input') {
      createButtonGroup(options, ['可視化 on', '可視化 off'], params.showOverlay ? 0 : 1, (i) => {
        params.showOverlay = i === 0;
      });
      return;
    }

    if (demoId === 'audio') {
      createButtonGroup(options, ['再生', '停止'], audio.playing ? 0 : 1, (i) => {
        // resume() はユーザー操作のハンドラの中から呼ぶ(4 節)
        if (i === 0) audio.start();
        else audio.stop();
      });
      createButtonGroup(options, ['スペクトル', '波形'], audioView === 'spectrum' ? 0 : 1, (i) => {
        audioView = i === 0 ? 'spectrum' : 'waveform';
      });
      return;
    }

    if (demoId === 'gui') {
      // GUI 自体が操作面なので option-buttons は要らない
      options.hidden = true;
      guiPanel.hidden = false;
      addColorPicker(guiPanel, {
        label: '基本色',
        value: params.colorHex,
        onInput: (hex) => {
          params.colorHex = hex;
          hexToLinear(hex, params.baseColor);
        },
      });
      addSlider(guiPanel, {
        label: '鋭さ (shininess)',
        min: 2,
        max: 256,
        step: 1,
        value: params.shininess,
        digits: 0,
        onInput: (value) => {
          params.shininess = value;
        },
      });
      addSlider(guiPanel, {
        label: '回転 (回/秒)',
        min: -1,
        max: 1,
        step: 0.01,
        value: params.spin,
        onInput: (value) => {
          params.spin = value;
        },
      });
      addSlider(guiPanel, {
        label: '環境光',
        min: 0,
        max: 2,
        step: 0.01,
        value: params.ambient,
        onInput: (value) => {
          params.ambient = value;
        },
      });
      addToggle(guiPanel, {
        label: '中の球を出す',
        value: params.showSphere,
        onChange: (value) => {
          params.showSphere = value;
        },
      });
      return;
    }

    // demoId === 'capture'
    captureControls.hidden = false;
    createButtonGroup(
      options,
      ['等倍', '2 倍', '4 倍'],
      captureScale === 1 ? 0 : captureScale === 2 ? 1 : 2,
      (i) => {
        captureScale = i === 0 ? 1 : i === 1 ? 2 : 4;
      },
    );
    captureButton = createActionButton(captureControls, '書き出す');
    captureButton.addEventListener('click', () => {
      releaseSaveUrl();
      captureRequested = true;
      if (captureButton) captureButton.disabled = true;
      captureNote = '書き出し中…';
    });
    const link = document.createElement('a');
    link.className = 'save-link';
    link.hidden = true;
    link.textContent = '保存';
    captureControls.append(link);
    saveLink = link;
  }

  function selectDemo(id: DemoId): void {
    demoId = id;
    // 音は止める。止め忘れると、別のデモを見ているあいだ鳴り続ける
    if (id !== 'audio') audio.stop();
    if (id !== 'capture') {
      cancelCapture();
      releaseSaveUrl();
    }
    buildOptions();
    // rAF 間隔の標本も捨てる。前のデモのフレームが混ざった平均を出さないため
    resetFrameSamples();
  }

  createButtonGroup(
    controls,
    DEMO_IDS.map((id) => DEMO_LABELS[id]),
    0,
    (index) => selectDemo(DEMO_IDS[index]),
  );
  buildOptions();

  // ---------------------------------------------------------------------------
  // リサイズ
  // ---------------------------------------------------------------------------

  function resizeIfNeeded(): void {
    const dpr = Math.min(window.devicePixelRatio, MAX_DPR);
    const width = Math.max(1, Math.floor(canvas.clientWidth * dpr));
    const height = Math.max(1, Math.floor(canvas.clientHeight * dpr));
    if (canvas.width !== width || canvas.height !== height) {
      canvas.width = width;
      canvas.height = height;
      // viewport も必ずセットで切り替える(既定値は 300×150 のまま)
      gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
    }
  }

  // ---------------------------------------------------------------------------
  // 描画
  // ---------------------------------------------------------------------------

  function drawMesh(mesh: Mesh, matrix: mat4, audioAmount: number): void {
    mat3.normalFromMat4(normalMatrix, matrix);
    gl.uniformMatrix4fv(sceneLocations.model, false, matrix); // transpose は常に false
    gl.uniformMatrix3fv(sceneLocations.normalMatrix, false, normalMatrix);
    gl.uniform1f(sceneLocations.audioAmount, audioAmount);
    gl.bindVertexArray(mesh.vao);
    gl.drawElements(gl.TRIANGLES, mesh.indexCount, gl.UNSIGNED_SHORT, 0);
    gl.bindVertexArray(null);
  }

  /**
   * いまバインドされている描画先へシーンを描く。
   * 射影行列を引数で受けるのは、7 節のタイル書き出しがここへ
   * 「視錐台の一部を切り出した行列」を渡すため
   */
  function drawScene(currentProjection: mat4, time: number): void {
    gl.enable(gl.DEPTH_TEST);
    gl.enable(gl.CULL_FACE);
    gl.clearColor(BACKGROUND_COLOR[0], BACKGROUND_COLOR[1], BACKGROUND_COLOR[2], 1.0);
    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

    gl.useProgram(sceneProgram);
    gl.uniformMatrix4fv(sceneLocations.view, false, view);
    gl.uniformMatrix4fv(sceneLocations.projection, false, currentProjection);
    gl.uniform3fv(sceneLocations.lightDirection, LIGHT_DIRECTION);
    gl.uniform3fv(sceneLocations.lightColor, LIGHT_COLOR);
    gl.uniform3fv(sceneLocations.cameraPosition, eye);
    gl.uniform3fv(sceneLocations.baseColor, params.baseColor);
    gl.uniform1f(sceneLocations.shininess, params.shininess);
    gl.uniform1f(sceneLocations.ambient, params.ambient);
    gl.uniform1f(sceneLocations.audioRow, audioView === 'spectrum' ? 0.25 : 0.75);
    gl.uniform1f(sceneLocations.audioBias, audioView === 'spectrum' ? 0.0 : WAVEFORM_CENTER);
    gl.uniform1f(sceneLocations.audioTint, demoId === 'audio' ? 1.2 : 0.0);
    gl.activeTexture(gl.TEXTURE0);
    gl.bindTexture(gl.TEXTURE_2D, audioTexture);
    gl.uniform1i(sceneLocations.audio, 0);

    // トーラス: 傾けてから回す。uv.x が大円 1 周なので、スペクトルが輪に巻かれる
    mat4.fromXRotation(model, -1.0);
    mat4.rotateZ(model, model, time * params.spin * Math.PI * 2);
    drawMesh(torus, model, demoId === 'audio' ? AUDIO_DISPLACEMENT : 0);

    // 中の球: 低音の強さでふくらむ。こちらは CPU 側で平均を取っている
    if (params.showSphere) {
      const scale = 1 + (demoId === 'audio' ? bassLevel * 0.7 : 0);
      mat4.fromScaling(model, [scale, scale, scale]);
      drawMesh(sphere, model, 0);
    }

    gl.disable(gl.CULL_FACE);
  }

  /** デモ 1 の可視化。シーンの上に重ねる UI なので、ふつうのアルファブレンド */
  function drawOverlay(): void {
    let count = 0;
    const push = (x: number, y: number, size: number, r: number, g: number, b: number): void => {
      if (count >= OVERLAY_CAPACITY) return;
      const at = count * OVERLAY_FLOATS;
      // 描画バッファ座標(左上原点)→ NDC(左下原点)
      overlayData[at] = (x / canvas.width) * 2 - 1;
      overlayData[at + 1] = 1 - (y / canvas.height) * 2;
      overlayData[at + 2] = size;
      overlayData[at + 3] = r;
      overlayData[at + 4] = g;
      overlayData[at + 5] = b;
      count++;
    };

    let midX = 0;
    let midY = 0;
    for (const pointer of input.pointers.values()) {
      push(pointer.downX, pointer.downY, 20, 0.05, 0.12, 0.35);
      push(pointer.x, pointer.y, 38, 0.9, 0.55, 0.15);
      midX += pointer.x;
      midY += pointer.y;
    }
    if (input.pointers.size === 2) {
      push(midX / 2, midY / 2, 28, 0.1, 0.6, 0.3);
    }
    if (count === 0) return;

    gl.useProgram(overlayProgram);
    gl.bindVertexArray(overlayVao);
    gl.bindBuffer(gl.ARRAY_BUFFER, overlayBuffer);
    gl.bufferSubData(gl.ARRAY_BUFFER, 0, overlayData, 0, count * OVERLAY_FLOATS);
    gl.disable(gl.DEPTH_TEST);
    gl.enable(gl.BLEND);
    gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
    gl.drawArrays(gl.POINTS, 0, count);
    gl.disable(gl.BLEND);
    gl.bindVertexArray(null);
    gl.bindBuffer(gl.ARRAY_BUFFER, null);
  }

  // ---------------------------------------------------------------------------
  // 読み出し行
  // ---------------------------------------------------------------------------

  const FRAME_SAMPLES = 30;
  const frameTimes: number[] = [];
  let lastReadoutAt = 0;

  function resetFrameSamples(): void {
    frameTimes.length = 0;
  }

  function averageFrameMs(): number {
    if (frameTimes.length === 0) return 0;
    let total = 0;
    for (const value of frameTimes) total += value;
    return total / frameTimes.length;
  }

  function describeInput(): string {
    const types = [...input.pointers.values()].map((p) => p.type || '(不明)');
    const keys = [...input.keys];
    return (
      `押されているポインタ ${input.pointers.size} 本` +
      `${types.length > 0 ? `(${types.join(', ')})` : ''} / ` +
      `キー {${keys.length > 0 ? keys.join(', ') : ''}} / ` +
      `ピンチ ${input.pinchScale.toFixed(3)} 倍 / ` +
      `getBoundingClientRect の呼び出し ${input.rectReads} 回(ページを開いてから)`
    );
  }

  function describeAudio(): string {
    if (audio.sampleRate === 0) {
      return `AudioContext はまだ作っていません(「再生」を押すと作ります・4 節) / fftSize ${audio.fftSize} / ビン数 ${BIN_COUNT}`;
    }
    return (
      `${audio.playing ? '再生中' : '停止中'} / sampleRate ${audio.sampleRate} Hz / ` +
      `fftSize ${audio.fftSize} / ビン数 ${BIN_COUNT} / ` +
      `1 ビン ${audio.hzPerBin.toFixed(2)} Hz(上端 ${(audio.sampleRate / 2 / 1000).toFixed(1)} kHz) / ` +
      `表示 ${audioView === 'spectrum' ? 'スペクトル' : '波形'} / ` +
      `毎フレーム ${AUDIO_BYTES_PER_FRAME.toLocaleString('en-US')} バイトを texSubImage2D で WebGL に渡す`
    );
  }

  function describeGui(): string {
    return (
      `基本色 ${params.colorHex}(リニア ` +
      `${params.baseColor[0].toFixed(3)}, ${params.baseColor[1].toFixed(3)}, ${params.baseColor[2].toFixed(3)}) / ` +
      `鋭さ ${params.shininess.toFixed(0)} / 回転 ${params.spin.toFixed(2)} 回/秒 / ` +
      `環境光 ${params.ambient.toFixed(2)} / 中の球 ${params.showSphere ? 'あり' : 'なし'}`
    );
  }

  function describeCapture(): string {
    const base = `${gl.drawingBufferWidth} × ${gl.drawingBufferHeight}`;
    const progress = job ? ` / タイル ${job.rendered} / ${job.total}` : '';
    return `描画バッファ ${base} / 倍率 ${captureScale} 倍${progress} / ${captureNote}`;
  }

  function updateReadout(): void {
    const ms = frameTimes.length === 0 ? '—' : `${averageFrameMs().toFixed(1)} ms`;
    const body =
      demoId === 'input'
        ? describeInput()
        : demoId === 'audio'
          ? describeAudio()
          : demoId === 'gui'
            ? describeGui()
            : describeCapture();
    readout.textContent = `${body} / rAF 間隔 ${ms}(CPU 側の計測。GPU 時間ではありません・第35章 3 節)`;
  }

  // ---------------------------------------------------------------------------
  // 描画ループ
  // ---------------------------------------------------------------------------

  let lastTimestamp = 0;

  function frame(timestamp: DOMHighResTimeStamp): void {
    resizeIfNeeded();
    const time = timestamp / 1000;

    const delta = timestamp - lastTimestamp;
    if (lastTimestamp !== 0 && delta < 200) {
      frameTimes.push(delta);
      if (frameTimes.length > FRAME_SAMPLES) frameTimes.shift();
    }
    const dt = lastTimestamp === 0 ? 0 : Math.min(delta / 1000, 1 / 15);
    lastTimestamp = timestamp;

    // --- 入力を「フレームで読む状態」として受け取る(1 節) --------------------------
    input.beginFrame();

    const drag = input.takeDrag();
    const speed = (2 * Math.PI) / canvas.height;
    orbit.theta -= drag.x * speed;
    orbit.phi += drag.y * speed;

    orbit.radius *= Math.exp(input.takeZoomLog());

    // キーはフレームで集合を読む。押されている間ずっと効く(3 節)
    const boost = input.modifiers.shift ? 3 : 1;
    const move = KEY_MOVE_SPEED * dt * boost;
    let forward = 0;
    let strafe = 0;
    if (input.keys.has('KeyW')) forward += 1;
    if (input.keys.has('KeyS')) forward -= 1;
    if (input.keys.has('KeyD')) strafe += 1;
    if (input.keys.has('KeyA')) strafe -= 1;
    if (input.keys.has('KeyQ')) orbit.radius *= Math.exp(KEY_ZOOM_SPEED * dt);
    if (input.keys.has('KeyE')) orbit.radius *= Math.exp(-KEY_ZOOM_SPEED * dt);
    if (input.keys.has('KeyR')) {
      orbit.theta = 0.6;
      orbit.phi = 0.3;
      orbit.radius = 4.4;
      vec3.set(target, 0, 0, 0);
    }

    orbit.phi = Math.min(PHI_LIMIT, Math.max(-PHI_LIMIT, orbit.phi));
    orbit.radius = Math.min(MAX_RADIUS, Math.max(MIN_RADIUS, orbit.radius));

    // カメラの水平基底。キーと 2 本指のパンはこの上で動かす
    vec3.set(cameraForward, -Math.sin(orbit.theta), 0, -Math.cos(orbit.theta));
    vec3.set(cameraRight, Math.cos(orbit.theta), 0, -Math.sin(orbit.theta));
    vec3.scaleAndAdd(target, target, cameraForward, forward * move);
    vec3.scaleAndAdd(target, target, cameraRight, strafe * move);

    const pan = input.takePan();
    // 2 本指のパン。画面の 1 px が、いまの距離で何ワールド単位かに直す
    const worldPerPixel = (2 * orbit.radius * Math.tan(FOVY / 2)) / canvas.height;
    vec3.scaleAndAdd(target, target, cameraRight, -pan.x * worldPerPixel);
    vec3.scaleAndAdd(target, target, UP, pan.y * worldPerPixel);

    // --- 音を読んで GPU へ(4 節) ---------------------------------------------------
    if (demoId === 'audio') {
      audio.update();
      audio.readFrequency(frequencyData);
      audio.readWaveform(waveformData);
      let total = 0;
      for (let i = 0; i < 16; i++) total += frequencyData[i];
      bassLevel = total / (16 * 255);

      gl.activeTexture(gl.TEXTURE0);
      gl.bindTexture(gl.TEXTURE_2D, audioTexture);
      gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
      gl.texSubImage2D(
        gl.TEXTURE_2D,
        0,
        0,
        0,
        BIN_COUNT,
        1,
        gl.RED,
        gl.UNSIGNED_BYTE,
        frequencyData,
      );
      // 波形は fftSize 個あるので、テクスチャの幅に合わせて**新しいほうから**ビン数ぶん使う
      gl.texSubImage2D(
        gl.TEXTURE_2D,
        0,
        0,
        1,
        BIN_COUNT,
        1,
        gl.RED,
        gl.UNSIGNED_BYTE,
        waveformData.subarray(audio.fftSize - BIN_COUNT),
      );
    }

    // --- カメラ行列 -------------------------------------------------------------------
    eye[0] = target[0] + orbit.radius * Math.cos(orbit.phi) * Math.sin(orbit.theta);
    eye[1] = target[1] + orbit.radius * Math.sin(orbit.phi);
    eye[2] = target[2] + orbit.radius * Math.cos(orbit.phi) * Math.cos(orbit.theta);
    mat4.lookAt(view, eye, target, UP);
    const aspect = gl.drawingBufferWidth / gl.drawingBufferHeight;
    mat4.perspective(projection, FOVY, aspect, NEAR, FAR);

    // --- 画面へ描く -----------------------------------------------------------------
    drawScene(projection, time);
    if (demoId === 'input' && params.showOverlay) {
      drawOverlay();
    }

    // --- 書き出し(6〜7 節) ------------------------------------------------------------
    // **描いた直後・同じタスクの中**でしか canvas.toBlob は正しい絵を返さない。
    // だから書き出しの処理はここ、rAF コールバックの末尾に置いてある
    if (captureRequested && captureScale === 1) {
      captureRequested = false;
      const width = gl.drawingBufferWidth;
      const height = gl.drawingBufferHeight;
      void canvasToBlob(canvas).then((blob) => {
        publish(blob, width, height, 1, 'canvas.toBlob(6 節)');
      });
    } else if (captureRequested) {
      captureRequested = false;
      job = beginTileCapture(gl, {
        tileWidth: gl.drawingBufferWidth,
        tileHeight: gl.drawingBufferHeight,
        tilesX: captureScale,
        tilesY: captureScale,
      });
    }

    if (job) {
      const current = job;
      const done = captureNextTile(gl, current, FOVY, NEAR, FAR, (tileProjectionMatrix) => {
        drawScene(tileProjectionMatrix, time);
      });
      captureNote = `書き出し中… タイル ${current.rendered} / ${current.total}`;
      if (done) {
        job = null;
        void finishTileCapture(current).then((blob) => {
          publish(blob, current.width, current.height, current.total, 'FBO タイル(7 節)');
          deleteTileCapture(gl, current);
        });
      }
    }

    if (timestamp - lastReadoutAt > 250) {
      updateReadout();
      lastReadoutAt = timestamp;
    }
    requestAnimationFrame(frame);
  }

  /**
   * 書き出せた PNG を「保存」リンクにぶら下げる。
   * ここで自動的にダウンロードを始めないのは意図的で、押すかどうかは読者が決める
   */
  function publish(
    blob: Blob | null,
    width: number,
    height: number,
    tiles: number,
    path: string,
  ): void {
    if (captureButton) captureButton.disabled = false;
    if (!blob) {
      captureNote = '書き出しに失敗しました';
      return;
    }
    releaseSaveUrl();
    const url = URL.createObjectURL(blob);
    saveUrl = url;
    if (saveLink) {
      saveLink.href = url;
      saveLink.download = `webgl2-36-${width}x${height}.png`;
      saveLink.textContent = `保存 (${width}×${height} PNG)`;
      saveLink.hidden = false;
    }
    const kib = blob.size / 1024;
    captureNote =
      `${path} / ${width} × ${height} px / タイル ${tiles} 枚 / ` +
      `PNG ${blob.size.toLocaleString('en-US')} バイト(${kib.toFixed(1)} KiB) / ` +
      `1 画素あたり ${((blob.size * 8) / (width * height)).toFixed(2)} ビット`;
  }

  // このページのデモはページと寿命を共にするので、rAF ループの停止もリスナーの解除も
  // していない。GPU リソース解放の一般論は第35章 7 節(この章の deleteTileCapture と
  // revokeObjectURL は「使い終わったら消す」ぶんだけを実践している)
  requestAnimationFrame(frame);
}

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

const canvas = document.querySelector<HTMLCanvasElement>('#demo');
const controls = document.querySelector<HTMLParagraphElement>('#demo-buttons');
const options = document.querySelector<HTMLParagraphElement>('#option-buttons');
const captureControls = document.querySelector<HTMLParagraphElement>('#capture-buttons');
const guiPanel = document.querySelector<HTMLDivElement>('#gui');
const readout = document.querySelector<HTMLParagraphElement>('#readout');
if (!canvas || !controls || !options || !captureControls || !guiPanel || !readout) {
  throw new Error('デモに必要な要素が見つかりません');
}

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

setup(gl, canvas, controls, options, captureControls, guiPanel, readout);
src/lessons/36-interaction-and-output/input.ts
// 第36章: 入力を 1 か所にまとめる小さな部品(本文 1〜3 節)
//
// 設計の芯は 1 つだけ ―― **イベントは状態を記録するだけで、絵の都合は一切知らない**。
// 絵は 1 フレームに 1 回描くので、フレームの先頭で状態を読み、
// 「前回読んでから溜まった量」(ドラッグ量・ズーム量)を受け取って使い切る。
//
// イベントの中で座標変換をしないのも同じ理由。getBoundingClientRect() は
// レイアウトを誘発する読み取りなので(第4章 6 節・第35章 4 節)、
// pointermove のたびに呼ぶのは避けたい。イベントは clientX / clientY を
// そのまま覚えておき、変換はフレームの先頭で 1 回だけ行う。
//
// 第34章のミニエンジンとは関係がない。この章はエンジンを使わない。

/** 1 本のポインタについて覚えていること。座標はイベントが来たときの生の値 */
export interface PointerState {
  /** mouse / pen / touch(PointerEvent の pointerType) */
  type: string;
  /** 押した瞬間の clientX / clientY */
  downClientX: number;
  downClientY: number;
  /** 最新の clientX / clientY */
  clientX: number;
  clientY: number;
  /** 描画バッファ座標(左上原点・px)。beginFrame() が毎フレーム入れ直す */
  x: number;
  y: number;
  /** 押し始めの描画バッファ座標 */
  downX: number;
  downY: number;
}

export interface Input {
  /** 押されているポインタ。キーは pointerId */
  readonly pointers: ReadonlyMap<number, PointerState>;
  /** 押されているキーの集合。要素は KeyboardEvent.code(3 節) */
  readonly keys: ReadonlySet<string>;
  /** 2 本指のとき、ジェスチャ開始時からの倍率。それ以外は 1 */
  readonly pinchScale: number;
  /** 修飾キー。フレームで読む */
  readonly modifiers: { shift: boolean; ctrl: boolean; alt: boolean; meta: boolean };
  /** キャッシュしている canvas の矩形(CSS px)。読み出し行の表示に使う */
  readonly rect: { width: number; height: number };
  /** getBoundingClientRect() を実際に呼んだ回数。1 節の「毎イベント読まない」の確認用 */
  readonly rectReads: number;
  /** フレームの先頭で呼ぶ。座標を描画バッファ系へ直し、溜まった量を確定させる */
  beginFrame(): void;
  /** 1 本指のドラッグ量(描画バッファ px)。読むと 0 に戻る */
  takeDrag(): { x: number; y: number };
  /** 2 本指の中点の移動量(描画バッファ px)。読むと 0 に戻る */
  takePan(): { x: number; y: number };
  /** ズーム量。距離の対数で持つ(正 = 引く)。ホイールとピンチの合計。読むと 0 に戻る */
  takeZoomLog(): number;
}

// WheelEvent.deltaMode の単位をピクセルへ寄せるための係数。
// 仕様(Pointer Events Level 4 §6.1.1)は DOM_DELTA_LINE / _PAGE を
// 「行」「ページ」と定めるだけで、1 行が何ピクセルかは決めていない
// ("The precise measurement is specific to device, operating system, and
//  application configurations.")。だからこの 2 つはアプリ側の取り決めになる。
// 第15・32章のホイール処理も同じ 16 / 100 を使っている
const LINE_HEIGHT_PX = 16;
const PAGE_HEIGHT_PX = 100;

// ホイールを 1 px 回したときのズーム量(距離の対数)。
// 対数で持つのは、ズームが乗算だから(第15章 4 節)。ピンチの倍率と単純に足せる
const WHEEL_ZOOM_PER_PIXEL = 0.001;

/**
 * canvas に対する入力をまとめて監視する。
 * キーは canvas ではなく window で拾う(canvas は既定でフォーカスを持たないため・3 節)。
 */
export function createInput(canvas: HTMLCanvasElement): Input {
  const pointers = new Map<number, PointerState>();
  const keys = new Set<string>();
  const modifiers = { shift: false, ctrl: false, alt: false, meta: false };

  // --- canvas の矩形のキャッシュ(1 節) -------------------------------------
  // getBoundingClientRect() はビューポート基準なので、大きさが変わらなくても
  // **スクロールしただけでずれる**。ResizeObserver だけでは足りないので、
  // scroll と resize でも汚れたことにする。実際に読み直すのは beginFrame()。
  let rectDirty = true;
  let rectLeft = 0;
  let rectTop = 0;
  let rectWidth = 1;
  let rectHeight = 1;
  let rectReads = 0;

  function markRectDirty(): void {
    rectDirty = true;
  }

  function refreshRect(): void {
    if (!rectDirty) return;
    const box = canvas.getBoundingClientRect();
    rectReads++;
    rectLeft = box.left;
    rectTop = box.top;
    rectWidth = Math.max(1, box.width);
    rectHeight = Math.max(1, box.height);
    rectDirty = false;
  }

  const resizeObserver = new ResizeObserver(markRectDirty);
  resizeObserver.observe(canvas);
  // capture: true で、内側のスクロールコンテナが動いたときも拾う
  window.addEventListener('scroll', markRectDirty, { capture: true, passive: true });
  window.addEventListener('resize', markRectDirty);

  /** CSS ピクセルの clientX / clientY を描画バッファ座標(左上原点)へ直す */
  function toBufferX(clientX: number): number {
    return ((clientX - rectLeft) / rectWidth) * canvas.width;
  }
  function toBufferY(clientY: number): number {
    return ((clientY - rectTop) / rectHeight) * canvas.height;
  }

  // --- 溜めておく量 -----------------------------------------------------------
  let dragX = 0;
  let dragY = 0;
  let panX = 0;
  let panY = 0;
  let zoomLog = 0;

  // --- ポインタ -----------------------------------------------------------------
  // pointerdown / pointermove / setPointerCapture の基本は第15章 3 節。
  // ここの担当は「pointerId ごとに状態を持ち、本数で挙動を変える」ところ

  /** 2 本指ジェスチャの基準。指の組み合わせが変わったら取り直す(2 節) */
  let gesture: { distance: number; midX: number; midY: number } | null = null;
  let pinchScale = 1;

  function twoPointers(): [PointerState, PointerState] | null {
    if (pointers.size !== 2) return null;
    const list = [...pointers.values()];
    return [list[0], list[1]];
  }

  /** いまの 2 本から基準を取り直す。本数が変わるたびに呼ぶ(3 本目でも、1 本抜けても) */
  function resetGesture(): void {
    const pair = twoPointers();
    if (!pair) {
      gesture = null;
      pinchScale = 1;
      return;
    }
    const dx = pair[1].clientX - pair[0].clientX;
    const dy = pair[1].clientY - pair[0].clientY;
    gesture = {
      // 0 除算を避ける。指がぴったり重なることは実際にある
      distance: Math.max(1, Math.hypot(dx, dy)),
      midX: (pair[0].clientX + pair[1].clientX) / 2,
      midY: (pair[0].clientY + pair[1].clientY) / 2,
    };
    pinchScale = 1;
  }

  canvas.addEventListener('pointerdown', (event) => {
    // マウスの右クリック・中クリックは無視する。touch / pen は button = 0
    if (event.pointerType === 'mouse' && event.button !== 0) return;
    pointers.set(event.pointerId, {
      type: event.pointerType,
      downClientX: event.clientX,
      downClientY: event.clientY,
      clientX: event.clientX,
      clientY: event.clientY,
      x: 0,
      y: 0,
      downX: 0,
      downY: 0,
    });
    // 捕獲しておくと、canvas の外へ出ても pointermove / pointerup が届く(第15章 3 節)
    canvas.setPointerCapture(event.pointerId);
    resetGesture();
  });

  canvas.addEventListener('pointermove', (event) => {
    const state = pointers.get(event.pointerId);
    if (!state) return; // 押していないポインタの移動(ホバー)は使わない

    const previousX = state.clientX;
    const previousY = state.clientY;
    state.clientX = event.clientX;
    state.clientY = event.clientY;

    if (pointers.size === 1) {
      // 1 本指: そのまま回転量として溜める。CSS px のまま溜めて、
      // 描画バッファ px への換算は beginFrame() でまとめて行う
      dragX += event.clientX - previousX;
      dragY += event.clientY - previousY;
      return;
    }

    const pair = twoPointers();
    if (!gesture || !pair) return;
    const dx = pair[1].clientX - pair[0].clientX;
    const dy = pair[1].clientY - pair[0].clientY;
    const distance = Math.max(1, Math.hypot(dx, dy));
    const midX = (pair[0].clientX + pair[1].clientX) / 2;
    const midY = (pair[0].clientY + pair[1].clientY) / 2;

    // 倍率は**開始時の距離との比**。基準が 1 つなので指の本数が変わっても置き直すだけで済む(2 節)
    const scale = distance / gesture.distance;
    // 前回の倍率との差だけをズーム量へ渡す。log の差なので加算でよい
    zoomLog -= Math.log(scale) - Math.log(pinchScale);
    pinchScale = scale;

    panX += midX - gesture.midX;
    panY += midY - gesture.midY;
    gesture.midX = midX;
    gesture.midY = midY;
  });

  function endPointer(event: PointerEvent): void {
    if (!pointers.delete(event.pointerId)) return;
    if (canvas.hasPointerCapture(event.pointerId)) {
      canvas.releasePointerCapture(event.pointerId);
    }
    // 1 本抜けたら基準を取り直す。取り直さないと、残った指の距離が
    // いきなり別の意味になって絵が飛ぶ(2 節)
    resetGesture();
  }

  // pointerup だけでは足りない。pointercancel(スクロールに取られた・
  // パームリジェクト・モーダルが開いた)を落とすと、押しっぱなしの状態が残る。
  // 仕様は「ポインタイベントの流れを抑止する」手順として
  // pointercancel → pointerout → pointerleave を順に発火すると定めている
  // (Pointer Events Level 3 §4.1.3.3)
  canvas.addEventListener('pointerup', endPointer);
  canvas.addEventListener('pointercancel', endPointer);
  // 捕獲が外れたときも念のため掃除する。捕獲中は pointerleave が来ない
  canvas.addEventListener('lostpointercapture', endPointer);

  // --- ホイール -------------------------------------------------------------------
  canvas.addEventListener(
    'wheel',
    (event) => {
      // ページのスクロールに取られないようにする。passive: false が要る
      event.preventDefault();
      const scale =
        event.deltaMode === WheelEvent.DOM_DELTA_LINE
          ? LINE_HEIGHT_PX
          : event.deltaMode === WheelEvent.DOM_DELTA_PAGE
            ? PAGE_HEIGHT_PX
            : 1;
      zoomLog += event.deltaY * scale * WHEEL_ZOOM_PER_PIXEL;
    },
    { passive: false },
  );

  // --- キーボード(3 節) -----------------------------------------------------------
  // canvas は既定でフォーカスを持たないので window で拾う。そのぶん、
  // ページの入力欄で打っている文字まで拾ってしまうので、そこは自分で外す
  function isTextEntry(target: EventTarget | null): boolean {
    if (!(target instanceof HTMLElement)) return false;
    if (target.isContentEditable) return true;
    const tag = target.tagName;
    return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
  }

  function readModifiers(event: KeyboardEvent): void {
    modifiers.shift = event.shiftKey;
    modifiers.ctrl = event.ctrlKey;
    modifiers.alt = event.altKey;
    modifiers.meta = event.metaKey;
  }

  window.addEventListener('keydown', (event) => {
    if (isTextEntry(event.target)) return;
    readModifiers(event);
    // event.repeat が true のイベントは無視する。集合はもう入っているし、
    // リピートの間隔は OS の設定で決まるので、絵の速さをそこに預けたくない(3 節)
    if (event.repeat) return;
    keys.add(event.code);
  });

  window.addEventListener('keyup', (event) => {
    readModifiers(event);
    keys.delete(event.code);
  });

  // タブを離れると keyup が来ないまま押しっぱなしの状態が残る。
  // フォーカスを失ったら集合を空にする(3 節)
  function clearKeys(): void {
    keys.clear();
    modifiers.shift = false;
    modifiers.ctrl = false;
    modifiers.alt = false;
    modifiers.meta = false;
  }
  window.addEventListener('blur', clearKeys);
  document.addEventListener('visibilitychange', () => {
    if (document.hidden) clearKeys();
  });

  // --- フレームの先頭で呼ぶ ---------------------------------------------------------
  const drag = { x: 0, y: 0 };
  const pan = { x: 0, y: 0 };

  return {
    pointers,
    keys,
    modifiers,
    get pinchScale() {
      return pinchScale;
    },
    get rect() {
      return { width: rectWidth, height: rectHeight };
    },
    get rectReads() {
      return rectReads;
    },
    beginFrame() {
      refreshRect();
      for (const state of pointers.values()) {
        state.x = toBufferX(state.clientX);
        state.y = toBufferY(state.clientY);
        state.downX = toBufferX(state.downClientX);
        state.downY = toBufferY(state.downClientY);
      }
    },
    takeDrag() {
      // CSS px で溜めた量を、描画バッファ px に直してから渡す
      const ratio = canvas.height / rectHeight;
      drag.x = dragX * ratio;
      drag.y = dragY * ratio;
      dragX = 0;
      dragY = 0;
      return drag;
    },
    takePan() {
      const ratio = canvas.height / rectHeight;
      pan.x = panX * ratio;
      pan.y = panY * ratio;
      panX = 0;
      panY = 0;
      return pan;
    },
    takeZoomLog() {
      const value = zoomLog;
      zoomLog = 0;
      return value;
    },
  };
}
src/lessons/36-interaction-and-output/audio.ts
// 第36章: 音源と FFT(本文 4 節)
//
// 音源は自前で作る。マイクも音声ファイルも使わない ―― 権限の確認や素材の配布を
// この章に持ち込まないため。オシレータ数本とノイズだけでも、スペクトルには
// 十分な構造が出る。
//
// 接続は 1 本道:
//   ボイス(オシレータ / ノイズ) → masterGain(0.1) → analyser → destination
// AnalyserNode は「音声ストリームを未加工のまま入力から出力へ通す」と
// 仕様が定めている(Web Audio API §1.8)ので、途中に挟んでも音は変わらない。

/** マスターの音量。いきなり大きい音が出ないように小さく取ってある */
const MASTER_GAIN = 0.1;

/**
 * FFT の窓の大きさ。2 の冪で 32〜32768(Web Audio API §1.8.2)。
 * frequencyBinCount = fftSize / 2 = 1024 が、そのままテクスチャの幅になる
 */
const FFT_SIZE = 2048;

/** 1 ステップの長さ(秒)。この間隔でフレーズが進む */
const STEP_SECONDS = 0.22;
/** 何秒先まで予約しておくか。rAF の間隔より十分長く取る */
const LOOKAHEAD_SECONDS = 0.25;

/** ペンタトニック 1 オクターブ半(A マイナー)。Hz 直書き */
// biome-ignore format: 音階として縦に読みたい
const SCALE_HZ = [
  220.00, 261.63, 293.66, 329.63, 392.00,
  440.00, 523.25, 587.33, 659.25, 783.99,
];

export interface AudioEngine {
  /** 鳴っているか */
  readonly playing: boolean;
  /** AudioContext.sampleRate。まだ作っていなければ 0 */
  readonly sampleRate: number;
  /** frequencyBinCount。fftSize / 2 */
  readonly binCount: number;
  /** fftSize */
  readonly fftSize: number;
  /** 1 ビンあたりの周波数(Hz) = sampleRate / fftSize。まだ作っていなければ 0 */
  readonly hzPerBin: number;
  /**
   * 再生を始める。**ユーザー操作のハンドラの中から呼ぶこと**(4 節)。
   * AudioContext は生成直後に "running" になるとは限らず、仕様は
   * 「"suspended" から "running" への遷移を許すかどうかはユーザーエージェント次第」
   * と定めている(Web Audio API §1.2 "allowed to start")
   */
  start(): void;
  /** 止める。AudioContext は捨てずに suspend する(ページに 1 つしか作らない) */
  stop(): void;
  /** フレームごとに呼ぶ。先の音を予約する */
  update(): void;
  /** スペクトル(0〜255 × binCount)を書き込む */
  readFrequency(out: Uint8Array<ArrayBuffer>): void;
  /** 波形(0〜255 × fftSize)を書き込む */
  readWaveform(out: Uint8Array<ArrayBuffer>): void;
}

export function createAudio(): AudioEngine {
  // ページに 1 つだけ。最初に start() が呼ばれるまで作らない
  let context: AudioContext | null = null;
  let analyser: AnalyserNode | null = null;
  let master: GainNode | null = null;
  let noiseBuffer: AudioBuffer | null = null;
  let playing = false;
  /** 次に音を置く時刻(AudioContext の時計) */
  let nextStepAt = 0;
  let step = 0;

  function ensureContext(): { context: AudioContext; analyser: AnalyserNode; master: GainNode } {
    if (context && analyser && master) {
      return { context, analyser, master };
    }
    const created = new AudioContext();
    const gain = created.createGain();
    gain.gain.value = MASTER_GAIN;
    const node = created.createAnalyser();
    node.fftSize = FFT_SIZE;
    // 既定のまま(minDecibels -100 / maxDecibels -30 / smoothingTimeConstant 0.8)。
    // 既定値は仕様が決めている(§1.8.2)ので、ここで書き換える理由が無い
    gain.connect(node);
    node.connect(created.destination);

    // ノイズ 1 秒ぶんを作って使い回す。白色ノイズはスペクトルが平らなので、
    // これだけだと絵に構造が出ない。あとでバンドパスに通して山を作る
    const length = Math.floor(created.sampleRate);
    const buffer = created.createBuffer(1, length, created.sampleRate);
    const samples = buffer.getChannelData(0);
    for (let i = 0; i < length; i++) {
      samples[i] = Math.random() * 2 - 1;
    }

    context = created;
    analyser = node;
    master = gain;
    noiseBuffer = buffer;
    return { context: created, analyser: node, master: gain };
  }

  /** 1 音。オシレータ + エンベロープを毎回作って、鳴り終わったら捨てる */
  function scheduleTone(at: number, hz: number, duration: number, level: number): void {
    if (!context || !master) return;
    const oscillator = context.createOscillator();
    oscillator.type = 'triangle';
    oscillator.frequency.setValueAtTime(hz, at);

    const envelope = context.createGain();
    // 0 から立ち上げると exponentialRampToValueAtTime が使えないので、
    // 立ち上がりは線形・減衰は指数にする(耳には指数のほうが自然に聞こえる)
    envelope.gain.setValueAtTime(0, at);
    envelope.gain.linearRampToValueAtTime(level, at + 0.01);
    envelope.gain.exponentialRampToValueAtTime(0.0001, at + duration);

    oscillator.connect(envelope);
    envelope.connect(master);
    oscillator.start(at);
    oscillator.stop(at + duration + 0.02);
  }

  /** 打点。ノイズをバンドパスに通して、スペクトルに山を作る */
  function scheduleNoise(at: number, hz: number, duration: number, level: number): void {
    if (!context || !master || !noiseBuffer) return;
    const source = context.createBufferSource();
    source.buffer = noiseBuffer;
    source.loop = true;

    const filter = context.createBiquadFilter();
    filter.type = 'bandpass';
    filter.frequency.setValueAtTime(hz, at);
    filter.Q.setValueAtTime(6, at);

    const envelope = context.createGain();
    envelope.gain.setValueAtTime(0, at);
    envelope.gain.linearRampToValueAtTime(level, at + 0.005);
    envelope.gain.exponentialRampToValueAtTime(0.0001, at + duration);

    source.connect(filter);
    filter.connect(envelope);
    envelope.connect(master);
    source.start(at);
    source.stop(at + duration + 0.02);
  }

  return {
    get playing() {
      return playing;
    },
    get sampleRate() {
      return context ? context.sampleRate : 0;
    },
    get binCount() {
      return FFT_SIZE / 2;
    },
    get fftSize() {
      return FFT_SIZE;
    },
    get hzPerBin() {
      return context ? context.sampleRate / FFT_SIZE : 0;
    },
    start() {
      const { context: ctx } = ensureContext();
      // ユーザー操作の中から呼ぶ。ここが autoplay policy の入口(4 節)
      void ctx.resume();
      if (!playing) {
        nextStepAt = ctx.currentTime + 0.05;
        playing = true;
      }
    },
    stop() {
      if (!context) return;
      playing = false;
      // close() ではなく suspend()。同じページで作り直さないため(4 節の pitfall)
      void context.suspend();
    },
    update() {
      if (!playing || !context) return;
      const now = context.currentTime;
      // rAF は 16 ms 前後でしか来ないし、間隔も揺れる。音は「いま鳴らす」のではなく
      // 「少し先の時刻を予約する」形にして、rAF の揺れを音に持ち込まない
      while (nextStepAt < now + LOOKAHEAD_SECONDS) {
        const index = step % SCALE_HZ.length;
        scheduleTone(nextStepAt, SCALE_HZ[index], STEP_SECONDS * 1.6, 0.5);
        // 1 オクターブ下を薄く重ねて、低いビンにも山を作る
        scheduleTone(nextStepAt, SCALE_HZ[index] / 2, STEP_SECONDS * 2.4, 0.25);
        if (step % 2 === 0) {
          scheduleNoise(nextStepAt, 1800 + (step % 4) * 900, 0.14, 0.35);
        }
        nextStepAt += STEP_SECONDS;
        step++;
      }
    },
    readFrequency(out) {
      if (analyser) analyser.getByteFrequencyData(out);
      else out.fill(0);
    },
    readWaveform(out) {
      // 無音のときの中心は 128(b[k] = floor(128 (1 + x[k])) で x = 0 のとき)
      if (analyser) analyser.getByteTimeDomainData(out);
      else out.fill(128);
    },
  };
}
src/lessons/36-interaction-and-output/gui.ts
// 第36章: 最小の GUI(本文 5 節)
//
// dat.GUI / lil-gui / Tweakpane を入れない理由は第6章 1 節と同じ ――
// 中身を知らないコードにこの章の主題(値が絵に届くまでの道)を隠されたくないから。
// 部品は 3 つだけで、それぞれ独立した関数にしてある。1 つの関数に型で分岐させると、
// 引数が「どの型のときに何が効くか」の表になって、かえって読みにくい。
//
// どの部品も、値をその場で uniform へ書いたりはしない。**状態オブジェクトを更新するだけ**で、
// 絵は次のフレームがその状態を読んで描く。入力の設計(1 節)とまったく同じ形。
//
// 見た目は .astro の scoped <style> に置いてある。JS が生やした要素には Astro の
// スコープ属性が付かないので、あちらでは :global(...) で書いている。

/** <label for> と <input id> を結ぶための連番。ページに 1 つで足りる */
let nextId = 0;

function createRow(parent: HTMLElement, labelText: string): { row: HTMLElement; id: string } {
  const row = document.createElement('div');
  row.className = 'gui-row';
  const id = `gui-${nextId++}`;
  const label = document.createElement('label');
  label.className = 'gui-label';
  // for と id で結んでおくと、ラベルを押しても部品が動くし、
  // これがそのまま支援技術に読まれる名前になる。見えるラベルがあるので
  // aria-label を重ねる必要は無い(重ねると、読まれる名前が別物になりうる)
  label.htmlFor = id;
  label.textContent = labelText;
  row.append(label);
  parent.append(row);
  return { row, id };
}

export interface SliderOptions {
  label: string;
  min: number;
  max: number;
  step: number;
  value: number;
  /** 数値の表示桁数 */
  digits?: number;
  onInput: (value: number) => void;
}

/** つまみ。ドラッグ中も値が届くように input を使う(change はドラッグが終わってから) */
export function addSlider(parent: HTMLElement, options: SliderOptions): void {
  const { row, id } = createRow(parent, options.label);
  const input = document.createElement('input');
  input.type = 'range';
  input.id = id;
  input.min = String(options.min);
  input.max = String(options.max);
  input.step = String(options.step);
  input.value = String(options.value);

  const digits = options.digits ?? 2;
  const output = document.createElement('output');
  output.className = 'gui-value';
  // HTMLOutputElement.htmlFor は DOMTokenList なので、属性で書く
  output.setAttribute('for', id);
  output.textContent = options.value.toFixed(digits);

  input.addEventListener('input', () => {
    const value = Number(input.value);
    output.textContent = value.toFixed(digits);
    options.onInput(value);
  });

  row.append(input, output);
}

export interface ColorOptions {
  label: string;
  /** "#rrggbb" */
  value: string;
  onInput: (hex: string) => void;
}

/** 色。返ってくるのは常に "#rrggbb" の sRGB エンコード値(リニア値ではない・第27章 1 節) */
export function addColorPicker(parent: HTMLElement, options: ColorOptions): void {
  const { row, id } = createRow(parent, options.label);
  const input = document.createElement('input');
  input.type = 'color';
  input.id = id;
  input.value = options.value;

  const output = document.createElement('output');
  output.className = 'gui-value';
  output.setAttribute('for', id);
  output.textContent = options.value;

  input.addEventListener('input', () => {
    output.textContent = input.value;
    options.onInput(input.value);
  });

  row.append(input, output);
}

export interface ToggleOptions {
  label: string;
  value: boolean;
  onChange: (value: boolean) => void;
}

/** on / off。押した瞬間に確定するので change でよい(input と同時に来る) */
export function addToggle(parent: HTMLElement, options: ToggleOptions): void {
  const { row, id } = createRow(parent, options.label);
  const input = document.createElement('input');
  input.type = 'checkbox';
  input.id = id;
  input.checked = options.value;
  input.addEventListener('change', () => {
    options.onChange(input.checked);
  });
  row.append(input);
}

/** 中身を空にする。デモを切り替えるたびに作り直すため */
export function clearGui(parent: HTMLElement): void {
  parent.replaceChildren();
}
src/lessons/36-interaction-and-output/capture.ts
// 第36章: 画面の書き出しと、画面より大きい書き出し(本文 6〜7 節)
//
// 道は 2 本ある。
//   ① canvas.toBlob() ―― 描画バッファをそのまま PNG にする。**描いた直後に呼ぶ**(6 節)
//   ② FBO に描いて readPixels ―― 射影行列を切り出してタイルに分け、貼り合わせる(7 節)
//
// ②は画面より大きい絵が作れる代わりに、タイルの境目で「画面座標に依存する処理」の
// 結果が変わる。この章のシーンはそういう処理を持っていないので継ぎ目は出ないが、
// 一般には出る(7 節の但し書き)。

import { mat4 } from 'gl-matrix';
import {
  bindRenderTarget,
  createRenderTarget,
  deleteRenderTarget,
  type RenderTarget,
} from '../../lib/framebuffer';

/** canvas.toBlob() を Promise で包んだだけのもの。type の既定値は仕様どおり image/png */
export function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob | null> {
  return new Promise((resolve) => {
    // toBlob は非同期だが、**ビットマップの複製は呼んだ時点で作られる**
    // (HTML 仕様 §4.12.5: "set result to a copy of this canvas element's bitmap" までは
    //  同期で、PNG への直列化だけが in parallel)。だから rAF の中で描いた直後に
    // 呼べば、あとで PNG になるのは「そのときの絵」で正しい
    canvas.toBlob((blob) => resolve(blob), 'image/png');
  });
}

export interface TileCaptureOptions {
  /** タイル 1 枚の大きさ(px)。ふつうは描画バッファと同じにする */
  tileWidth: number;
  tileHeight: number;
  /** 横・縦のタイル数 */
  tilesX: number;
  tilesY: number;
}

export interface TileCapture {
  readonly tileWidth: number;
  readonly tileHeight: number;
  readonly tilesX: number;
  readonly tilesY: number;
  /** 仕上がりの大きさ(px) */
  readonly width: number;
  readonly height: number;
  /** タイルの総数 */
  readonly total: number;
  /** これまでに描いたタイル数 */
  rendered: number;
  /** 貼り合わせ先。RGBA8・上下は PNG と同じ「上が先」 */
  readonly pixels: Uint8ClampedArray<ArrayBuffer>;
  /** タイル 1 枚を描く FBO */
  readonly target: RenderTarget;
  /** readPixels の受け皿。1 枚ぶんを使い回す */
  readonly scratch: Uint8Array<ArrayBuffer>;
}

/**
 * タイル書き出しを始める。FBO は 1 枚だけ作って全タイルで使い回す。
 * 画面より大きい絵を「1 枚の巨大な FBO」で作らないのは、
 * MAX_TEXTURE_SIZE / MAX_RENDERBUFFER_SIZE の最小要求値が 2048 しかないから(7 節)。
 */
export function beginTileCapture(
  gl: WebGL2RenderingContext,
  options: TileCaptureOptions,
): TileCapture {
  const { tileWidth, tileHeight, tilesX, tilesY } = options;
  const width = tileWidth * tilesX;
  const height = tileHeight * tilesY;
  return {
    tileWidth,
    tileHeight,
    tilesX,
    tilesY,
    width,
    height,
    total: tilesX * tilesY,
    rendered: 0,
    pixels: new Uint8ClampedArray(width * height * 4),
    // 深度は要る(シーンに前後関係があるので)。カラーは RGBA8 ―― readPixels が
    // 常に読める組み合わせが RGBA / UNSIGNED_BYTE なのは、この形式のとき(7 節)
    target: createRenderTarget(gl, tileWidth, tileHeight, { depth: true }),
    scratch: new Uint8Array(tileWidth * tileHeight * 4),
  };
}

/**
 * 視錐台の一部を切り出す射影行列を作る。
 * perspective(fovy, aspect, near, far) は frustum の**左右上下が対称な特別な場合**で、
 *   top = near * tan(fovy / 2), right = top * aspect
 * を使って frustum(-right, right, -top, top, near, far) と書いたのと同じもの(第12章 4 節)。
 * タイルは、この [-right, right] × [-top, top] を格子に切った 1 区画になる。
 *
 * col は左から、row は**下から**数える(readPixels の原点に合わせてある)。
 */
export function tileProjection(
  out: mat4,
  fovy: number,
  aspect: number,
  near: number,
  far: number,
  tilesX: number,
  tilesY: number,
  col: number,
  row: number,
): mat4 {
  const top = near * Math.tan(fovy / 2);
  const right = top * aspect;
  return mat4.frustum(
    out,
    -right + ((2 * right) / tilesX) * col,
    -right + ((2 * right) / tilesX) * (col + 1),
    -top + ((2 * top) / tilesY) * row,
    -top + ((2 * top) / tilesY) * (row + 1),
    near,
    far,
  );
}

/**
 * 次の 1 枚を描いて読み取る。全部終わったら true を返す。
 * 1 フレームに 1 枚だけ進めるのは、16 枚を 1 フレームで回すとその間ページが固まるため。
 *
 * drawTile は「いまバインドされている FBO へ、渡された射影行列でシーンを描く」だけ。
 */
export function captureNextTile(
  gl: WebGL2RenderingContext,
  job: TileCapture,
  fovy: number,
  near: number,
  far: number,
  drawTile: (projection: mat4) => void,
): boolean {
  const projection = mat4.create();
  const col = job.rendered % job.tilesX;
  const row = Math.floor(job.rendered / job.tilesX);
  // アスペクト比は**仕上がり全体**のもの。タイル 1 枚の縦横比ではない
  tileProjection(
    projection,
    fovy,
    job.width / job.height,
    near,
    far,
    job.tilesX,
    job.tilesY,
    col,
    row,
  );

  bindRenderTarget(gl, job.target);
  drawTile(projection);

  // PACK_ALIGNMENT の既定は 4(ES 3.0.6 Table 4.4)。RGBA / UNSIGNED_BYTE は
  // 1 画素 4 バイトなので、幅が何であっても行の先頭は 4 の倍数に乗る = 詰め物は入らない
  gl.readPixels(0, 0, job.tileWidth, job.tileHeight, gl.RGBA, gl.UNSIGNED_BYTE, job.scratch);
  bindRenderTarget(gl, null);

  // readPixels は**左下原点**、ImageData は上が先。ここで行の並びを逆にしておけば、
  // あとでもう一度全面をひっくり返す必要がない(7 節)
  const rowBytes = job.tileWidth * 4;
  const destRowBytes = job.width * 4;
  for (let y = 0; y < job.tileHeight; y++) {
    const destY = job.height - (row * job.tileHeight + y) - 1;
    const destAt = destY * destRowBytes + col * rowBytes;
    job.pixels.set(job.scratch.subarray(y * rowBytes, (y + 1) * rowBytes), destAt);
  }

  job.rendered++;
  return job.rendered >= job.total;
}

/**
 * 貼り合わせた画素を PNG にする。
 * WebGL から直接 PNG は作れないので、2D canvas を 1 枚だけ経由する。
 * この canvas は WebGL コンテキストではないので「1 ページ 1〜2 個まで」の制限には当たらないが、
 * 数十 MB のビットマップを抱えるので、使い終わったら 0×0 にして手放す。
 */
export async function finishTileCapture(job: TileCapture): Promise<Blob | null> {
  const canvas = document.createElement('canvas');
  canvas.width = job.width;
  canvas.height = job.height;
  const context = canvas.getContext('2d');
  if (!context) {
    return null;
  }
  context.putImageData(new ImageData(job.pixels, job.width, job.height), 0, 0);
  try {
    return await canvasToBlob(canvas);
  } finally {
    // 数十 MB のビットマップをすぐ手放す
    canvas.width = 0;
    canvas.height = 0;
  }
}

/** FBO を解放する。書き出しが終わったら必ず呼ぶ(解放の一般論は第35章 7 節) */
export function deleteTileCapture(gl: WebGL2RenderingContext, job: TileCapture): void {
  deleteRenderTarget(gl, job.target);
}
src/lessons/36-interaction-and-output/scene.vert
#version 300 es

// 4 本のデモが共用する頂点シェーダー。
// シーンの組み立て自体はこの章の主題ではないので、形は第14章の createTorus /
// createSphere、陰影は第17章の Blinn-Phong をそのまま使っている。
//
// この章の固有部分は 1 か所だけ ―― u_audio(4 節)。
// 幅 = FFT のビン数・高さ 2 の R8 テクスチャで、行 0 がスペクトル、行 1 が波形。
// 頂点シェーダーからテクスチャを読めることは仕様が保証している
// (MAX_VERTEX_TEXTURE_IMAGE_UNITS の最小要求値は 16・ES 3.0.6 Table 6.31)。

layout(location = 0) in vec3 a_position;
layout(location = 1) in vec3 a_normal;
layout(location = 2) in vec2 a_uv;

uniform mat4 u_model;
uniform mat4 u_view;
uniform mat4 u_projection;
uniform mat3 u_normalMatrix; // mat3.normalFromMat4 で作った法線行列(第17章)

// 音のテクスチャ。値を後段の計算(変位)に使うので highp を明示する(第21章 6 節)
uniform highp sampler2D u_audio;
// 変位の大きさ(ワールド単位)。音を使わないデモでは 0
uniform float u_audioAmount;
// 読む行の v 座標。0.25 = スペクトルの行、0.75 = 波形の行(高さ 2 のテクセル中心)
uniform float u_audioRow;
// 読んだ値の「無音の位置」。スペクトルは 0、波形は 128/255 ≒ 0.502(4 節)
uniform float u_audioBias;

out vec3 v_normal;
out vec3 v_worldPosition;
out float v_audio; // 0 を中心にした音の値。フラグメント側で色に混ぜる

void main() {
  float sampled = texture(u_audio, vec2(a_uv.x, u_audioRow)).r - u_audioBias;
  v_audio = sampled;

  // 法線方向へ押し出すだけで、法線そのものは計算し直していない。
  // 陰影が変位に追従しないぶん形は素直に読めるので、この章ではこれで足りる
  vec3 displaced = a_position + a_normal * (u_audioAmount * sampled);

  vec4 worldPosition = u_model * vec4(displaced, 1.0);
  v_worldPosition = worldPosition.xyz;
  v_normal = u_normalMatrix * a_normal;

  gl_Position = u_projection * u_view * worldPosition;
}
src/lessons/36-interaction-and-output/scene.frag
#version 300 es

// 4 本のデモが共用するフラグメントシェーダー。陰影は第17章の Blinn-Phong で、
// 再解説はしない。この章が触るのは uniform の出どころのほうで、
// u_baseColor / u_shininess は 5 節の GUI が、v_audio は 4 節の音が動かす。

precision highp float;

in vec3 v_normal; // ワールド空間の法線(第17章の約束)
in vec3 v_worldPosition;
in float v_audio;

// 面から光源へ向かう単位ベクトル(第17章の約束。光が進む向きではない)
uniform vec3 u_lightDirection;
uniform vec3 u_lightColor;
uniform vec3 u_cameraPosition;
uniform vec3 u_baseColor; // リニア値。GUI の <input type="color"> から来る(5 節)
uniform float u_shininess;
uniform float u_ambient; // 環境光の強さ。GUI のつまみ(5 節)
uniform float u_audioTint; // 音で色を持ち上げる量。音を使わないデモでは 0

out vec4 fragColor;

#include "color.glsl"

void main() {
  vec3 N = normalize(v_normal); // 補間で縮んだ法線を戻す(第17章)
  vec3 L = u_lightDirection;
  vec3 V = normalize(u_cameraPosition - v_worldPosition);
  vec3 H = normalize(L + V);

  float diffuse = max(dot(N, L), 0.0);
  float specular = pow(max(dot(N, H), 0.0), u_shininess);

  // 隙間が真っ黒にならない程度の環境光(第17章の定数の環境光と同じ扱い)
  vec3 ambient = vec3(0.14, 0.16, 0.21) * u_ambient;
  vec3 base = u_baseColor * (1.0 + u_audioTint * v_audio);

  vec3 linear = base * (ambient + u_lightColor * diffuse) + u_lightColor * specular * 0.35;

  // リニア空間で計算し、画面へ出す直前の 1 か所だけでエンコードする(第27章 2 節)
  fragColor = vec4(linearToSrgb(linear), 1.0);
}
src/lessons/36-interaction-and-output/overlay.vert
#version 300 es

// デモ 1 の可視化だけが使う、いちばん小さい 2D 描画。
// ポインタの現在地・押し始めた場所・2 本指の中点を gl.POINTS で重ねる。
// 点の描き方そのものは第30章 5 節。
//
// 属性は 1 本の動的バッファにインターリーブしてある:
//   [x, y, size, r, g, b] = 6 float / 点
// x, y は NDC(-1〜1)。CPU 側で描画バッファ座標から直しているのは、
// 「入力の座標系をどこで揃えるか」を 1 節で 1 か所に決めたから。

layout(location = 0) in vec2 a_position; // NDC
layout(location = 1) in float a_size; // 直径(描画バッファ px)
layout(location = 2) in vec3 a_color; // リニア値

out vec3 v_color;

void main() {
  v_color = a_color;
  gl_PointSize = a_size; // 書かないと未定義(ES 3.0.6 §3.4)
  gl_Position = vec4(a_position, 0.0, 1.0);
}
src/lessons/36-interaction-and-output/overlay.frag
#version 300 es

// デモ 1 の可視化。点を輪(リング)として描く。
// gl_PointCoord の t は上→下(ES 3.0.6 §3.4.1。第30章 5 節)だが、
// 中心からの距離しか使わないので向きは効かない。

precision highp float;

in vec3 v_color;

out vec4 fragColor;

#include "color.glsl"

void main() {
  // 中心からの距離を 0〜1 に。0.5 が点の縁
  float d = length(gl_PointCoord - vec2(0.5));
  // 縁の内側だけを残した輪。fwidth でだいたい 1 px のなだらかさにする(第8章)
  float w = fwidth(d) * 1.5;
  float ring = smoothstep(0.5, 0.5 - w, d) * smoothstep(0.32 - w, 0.32, d);
  if (ring <= 0.0) {
    discard;
  }
  fragColor = vec4(linearToSrgb(v_color), ring);
}
src/lessons/36-interaction-and-output/color.glsl
// 第36章 出力の最後の 1 行のための色変換。
// 中身は第27章の src/lessons/27-color-and-palette/color.glsl からの複製(内容は同一)で、
// srgbToLinear / linearToSrgb の 2 関数だけを持ってきている。
// 片方を直したらもう片方も直すこと(理由は docs/plan.md の抽象化タイムライン:
// 読者が壊して遊ぶ対象なので章をまたいだ結合を作らない)。
//
// シェーダーで使うのは linearToSrgb だけ ―― 画面へ出す直前の 1 行(第27章 2 節)。
// srgbToLinear のほうは、GUI の <input type="color"> が返す sRGB の色をリニアへ直すために
// CPU 側(main.ts の srgbToLinear)で同じ式を書き写して使っている(5 節)。

/**
 * sRGB エンコード値 → リニア値。
 * 出典: OpenGL ES 3.0.6 §3.8.16 式 (3.26)。GPU が sRGB テクスチャを読むときの変換そのもの。
 * 境界 (0.04045) では 2 つの式の差が 2.3e-9 しかないので、等号がどちら側かは問題にならない。
 */
vec3 srgbToLinear(vec3 c) {
  vec3 lo = c / 12.92;
  vec3 hi = pow((c + 0.055) / 1.055, vec3(2.4));
  return mix(lo, hi, step(vec3(0.04045), c));
}

/**
 * リニア値 → sRGB エンコード値。画面へ出す最後の 1 行はこれになる。
 * 出典: OpenGL ES 3.0.6 §4.1.8 式 (4.1)。仕様は指数を 0.41666 と書いているが、
 * これは 1/2.4 = 0.4166666… を打ち切った値で、8 ビット出力での差は最大 1 段。1/2.4 を使う。
 * 仕様の式は 0 以下と 1 以上を切り落とすので、clamp がその 2 本の枝にあたる。
 */
vec3 linearToSrgb(vec3 c) {
  c = clamp(c, 0.0, 1.0);
  vec3 lo = c * 12.92;
  vec3 hi = 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055;
  return mix(lo, hi, step(vec3(0.0031308), c));
}

three.js との対応

右の列がこの章です。左の列の内容はthree@0.185.1を unpkg から取り寄せて、実際のソースで確かめました(2026-08-08 時点)。

three.jsこの章
OrbitControlsのタッチ対応(1 本指で回転、2 本指でピンチとパン)input.tsがpointerIdごとに状態を持ち、本数で挙動を分ける(1〜2 節)。OrbitControls.jsも同じ構図で、_pointersというpointerIdの配列を持ち、switch (this._pointers.length)でtouches.ONE(既定TOUCH.ROTATE)とtouches.TWO(既定TOUCH.DOLLY_PAN)に振り分けています。pointercancelの購読とsetPointerCapture、そしてdomElement.style.touchAction = 'none'も同じ
Raycaster(画面上の点から物体を特定する)この章では扱いません。入力の座標を描画バッファ系まで持ってくるところ (1 節)で止めてあります。そこからレイを作るなら第12章の基底と第28章のレイ、 ID バッファで解くなら第23章の MRT
THREE.Audio+THREE.AudioAnalyser(内部でAnalyserNodeを持ち、getFrequencyData()/getAverageFrequency()を出す)audio.tsがAudioContext/AnalyserNodeを直接持つ(4 節)。AudioAnalyser.jsも中身はcontext.createAnalyser()+fftSize(既定 2048)+new Uint8Array(frequencyBinCount)+getByteFrequencyDataで、この章とほぼ同じです。違うのはgetAverageFrequency()のような便利メソッドがあることと、値の意味(0〜255 が dB の目盛りであること)を 自分で扱うかどうか
renderer.domElement.toBlob(...)+preserveDrawingBuffer: trueを勧める記事が多いpreserveDrawingBufferは使わない。rAF の中で描いた直後にtoBlobを呼ぶ(6 節)。three.js でもrenderer.render()の直後に呼べば同じことができます
高解像度書き出し —renderer.setSize(w * n, h * n)で一気に大きくするFBO のタイル分割(7 節)。MAX_TEXTURE_SIZEの最小要求値 2048 に当たらないための手です。three.js には同じ計算がPerspectiveCamera.setViewOffset()として入っていて、updateProjectionMatrix()の中身はtop = near · tan(fov/2)を出してからview.offsetX/view.widthで区間を切り、makePerspective(left, left + width, top, top - height, …)を呼ぶ ―― この章のtileProjectionとまったく同じ形です(ソースの doc コメント自体が 3 × 2 のモニタに分割する例になっています)
WebGPURenderer+ TSL(three.js のシェーディング言語)この本は最後まで生の WebGL2 と GLSL ES 3.00 のままです(8 節)。 WebGPU へ移るときに書き直すのはシェーダーと API の呼び方で、 座標系・パイプラインの段・色の扱いは持っていけます

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

この章のコードはsrc/lessons/36-interaction-and-output/にあります。デモを切り替えると音は止まり、書き出し中のタイルは破棄されます。以下の予告のうち、「確かめてください」と書いてあるものは実機で見ていません— 何が起きるかを自分の目で確認してから、予想と合っているかを考えてみてください。

まとめ

36 章で積み上げたもの

第1部は「GPU に絵を描かせる最小の一式」でした。コンテキストを取り、頂点を送り、 シェーダーをコンパイルしてリンクし、drawArraysを呼ぶ。第1章で書いた「各要素は互いの結果に依存できない」という制約が、 そのあと 35 章にわたって効き続けます — ノイズが座標から決まるハッシュになるのも(第25章)、 フィードバックにパスが 2 本要るのも(第29章)、transform feedback が「互いに独立な粒子」しか扱えないのも(第32章)、全部この 1 行に戻ります。

第2部は、全画面 1 枚のシェーダーの上で数学をやり直しました。UV とアスペクト比、smoothstep、距離、三角関数、極座標、mat2。 ここで「画素ごとに独立な計算で形が出る」感覚を作ったので、第4部の SDF とレイマーチングは地続きになりました。

第3部は 3D のラスタライズパイプラインを、下から順に組み立てました。行列と 同次座標、深度テスト、カリング、ジオメトリ生成、カメラ、テクスチャ、ライティング、モデル読み込み、 FBO、シャドウマッピング、PBR、ディファード。この部だけで 13 章あり、この本でいちばん長い部分です。src/lib/に置いた共通モジュールのうち、geometry.ts(第14章)とframebuffer.ts(第20章)はここで生まれました(shader.tsは第6章から使い続けているものです) —共通化する理由が読者に見えてから抽象化するという順番を守った結果です。

第4部は絵作りの側でした。SDF、ハッシュとノイズ、fbm とドメインワーピング、 色とパレット、レイマーチング、フィードバック。ここでいちばん時間を使ったのは色だと思います — sRGB エンコード値とリニア値を区別すること、変換は入口と 出口の 2 か所だけにすること。これは 3D にも 2D にも効く、API から独立した約束です。

第5部は「量」と「統合」でした。ブレンドとパーティクル、インスタンシング、 transform feedback、ポストプロセス、UBO とミニエンジン、計測とロバスト性、そしてこの章。 第5部を通して一貫していたのは数えられる量でコストを語るという規律です — ドローコール数、WebGL 呼び出し回数、アプリが渡したバイト数。第26章のpcg3dの呼び出し回数や第28章のmap()の評価回数から続けてきたやり方で、GPU 時間を測る道具は第35章まで出しませんでした。

docs/plan.mdの冒頭には、この本の前提が 7 行書いてあります。「three.js 等の抽象化レイヤーを使わず、生の WebGL2 API + GLSL ES 3.00 で描画パイプラインを理解する」「動くものを最速で作ることではなく、理解が目的」「数学的素養を前提としない。必要な数学は登場した箇所で解説する」。 この 3 つはおおむね守れたと思います —src/lib/に入ったのは 4 つのモジュール(shader.ts/geometry.ts/framebuffer.ts/fullscreen-shader.ts)と、全画面三角形の頂点シェーダーfullscreen.vertだけで、どれも「なぜいま共通化するのか」を本文に書いてから導入しました。第34章が最後に「上げなかったもの」を並べたのも同じ流儀です。

達成できていないこともあります。「最終目標はジェネラティブ/シェーダーアート/ インタラクティブ表現」と書いてありますが、この本には「作品」が 1 つもありません。 あるのは章ごとの、その章の論点だけを見せるデモです。それは意図した構成ですが、 「作品を作る」ところは読者に残されたままです。もうひとつ、実測値の多くは 1 つの環境(macOS の Chrome / ANGLE Metal)でしか取っていません。仕様から演繹できる 部分と、手元で観測した部分は本文で書き分けてありますが、後者はあなたの環境では 違う値になりえます。読み出し行にできるだけ実測を出しているのは、そのためです。

この本を通してやっていたことを 1 つだけ選ぶなら、「なぜそうなるのか」を仕様の原文まで 降りて確かめることでした。gl_PointCoordのtが上から下だと確かめたのも(第30章 5 節)、fract(sin())を使わないと決めたのも(第25章)、「深度クリアの消し忘れは既定のフレームバッファでは 症状が出ない」と訂正したのも(第13章 4 節)、全部そこからでした。仕様は読みにくい文書ですが、「たぶんこうだろう」を「§ 何番にこう書いてある」に変えられるのは、 この文書だけです。次に何を作るにしても、その癖は持っていってください。