第4部 プロシージャル & ジェネラティブ — 第29章

フィードバック

第1章で、GPU のプログラミングモデルの帰結として 2 つのことを書きました。1 つは「シェーダーは 1 要素分だけを書く」。もう 1 つが、これです — 各要素は互いの結果に依存できない。 実行順序が保証されず、他の要素の計算結果を読む手段も基本的にないので、「隣のピクセルの結果を見て 自分の色を決める」ようには書けない。あのとき「この制約をどう乗り越えるかが、後半の面白いテーマに なります(第29章のフィードバックなど)」と予告しました。ここがその章です。

乗り越え方は、拍子抜けするほど単純です。1 パスの中では隣を読めない。ならばパスを 2 回に 分ければよい。1 回目のパスが全ピクセルを描き終えたあとなら、その結果はもう「完成した テクスチャ」です。2 回目のパスは、それを普通のテクスチャとして好きな場所から読めます。 自分の隣も、その隣も。代償は参照が 1 ステップ遅れることだけ。1 フレームに 1 ステップ進める設定ならそのまま 1 フレームの遅れですが、この章のリアクション・ ディフュージョンのように 1 フレームで 8 ステップ進めることもできます(3 節)。この仕掛けをフィードバック (feedback)と呼びます。

この章のデモ 3 本。ボタンで切り替えられます。「トレイル」は前の状態を少し暗くして光を足し続ける 残像(ポインタで描けます。自動で動く点も 1 つ走っていて、こちらは常に軌跡を描きます)、 「ライフゲーム」は近傍 8 セルから次の世代を決める粗い格子、 「リアクション・ディフュージョン」は 2 つの濃度が反応しながら拡散する Gray-Scott モデル(ポインタ左右で k、上下で F)。読み出し行には、状態テクスチャに実際に使われた形式が出ます。

この章で学ぶこと:

1. 前フレームを読むということ — 第1章の制約の正体

制約の正体は順序です。1 回のドローコールで走る何十万個のフラグメントシェーダーに、 「どれが先に終わるか」の保証はありません。だから「隣のピクセルの結果を読む」という操作は そもそも意味を持ちません — 読もうとした瞬間に、その隣はまだ走っていないかもしれないし、走り終えて いるかもしれない。第25章で「GPU に状態を持つ乱数生成器は置けない」と言ったのも同じ話でした。

ところがパスの境目には、順序があります。1 本目のドローコールが終わってから 2 本目を出すのは CPU 側の仕事で、こちらは順番どおりに並びます。1 本目の出力先をテクスチャにしておけば(第20章)、2 本目のシェーダーからは全ピクセルぶんが確定した 1 枚の絵として見えます。ここに、第9章で置いた宿題の答えがあります。

第9章のデモ 3 で、ポインタで惑星の速度を変えると軌道が滑らかに加減速せず一瞬でワープしました。 実験リストにこう書いてあります。「角度を『速度 × 経過時間』で毎フレーム計算し直しているので、 速度を変えると過去の分までまとめて掛け直される — 滑らかに変えるには角度を積み上げる必要があり、それには前フレームの状態の保存が要ります。第29章の伏線です」。状態の積み上げが、この章でできるようになることです。前フレームの角度を読んで、 そこに今フレームぶんの増分を足して、書き戻す。積分がこれで書けます。

ただし、素直に「テクスチャを読みながら同じテクスチャに描く」ことはできません。第20章 9 節でこう予告していました。「FBO を 2 つ用意し、片方に描きながらもう片方を読む ping-pong で、前フレームの結果を参照します(同じテクスチャを描画先とサンプル元に同時に使うことはできない ため、2 つ要ります)」。ここを仕様で確かめておきます。

OpenGL ES 3.0.6 の §4.4.3「Feedback Loops Between Textures and the Framebuffer」は、まず一般論を こう書いています。「テクスチャオブジェクトが GL の操作のソースと宛先の両方として 使われるとき、フィードバックループが存在しうる。フィードバックループが存在すると未定義の動作になる」。続く §4.4.3.1「Rendering Feedback Loops」は、たちの悪い点を明記しています — この状態でもフレームバッファは完全 (framebuffer complete) と判定される。つまり第20章 4 節の completeness チェックは通ってしまい、症状として残るのは「描かれる値が未定義」だけ です。しかもこの規定は、そのテクスチャを読む命令が条件分岐の中にあって実際には 実行されないとしても成立します(§4.4.3.1 の「even if those instructions might only be executed conditionally」)。

だから、テクスチャは2 枚用意します。片方を読み、もう片方へ書く。そして次の ステップでは役を入れ替える。卓球のラリーのように 2 枚が行き来するので、この手法をping-pongと呼びます。

テクスチャ Aテクスチャ Bステップ 0読む書く更新パスステップ 1書く読む更新パスステップ 2読む書く更新パス画面へ出すのは、そのステップで「書く」側だったほう(= 最新の状態)
ping-pong。2 枚のテクスチャは固定で、役だけが毎ステップ入れ替わります。 「読む」側は、そのステップのあいだ一度も書き換わりません。これが「完成した結果を読む」 という保証の中身です。

2. ping-pong — 2 枚の FBO を入れ替える

1 フレームでやることは 4 つだけです。

  1. 前の状態を読む — 片方のテクスチャをサンプラにバインドする
  2. 新しい状態を書く — もう片方の FBO を描画先にして、更新パスを 1 回描く
  3. 役を入れ替える — 変数を 2 つ入れ替えるだけ。データは動かない
  4. 画面へ表示する — 描画先を画面に戻して、表示パスを 1 回描く
src/lessons/29-feedback/feedback.ts(抜粋)
    // --- 更新パス: 前の状態を読み、新しい状態を書き、役を入れ替える -----------
    gl.useProgram(simProgram);
    stepBudget += stepsPerFrame;
    while (stepBudget >= 1) {
      stepBudget -= 1;
      // 描画先を write に切り替える(viewport も一緒に切り替わる)
      bindRenderTarget(gl, write);

FBO の API そのもの — createFramebuffer・framebufferTexture2D・checkFramebufferStatus・viewportの切り替え — は第20章で一つずつ書いたので、ここでは同章のヘルパーbindRenderTargetを呼ぶだけです。この関数の存在意義は「描画先と viewportを必ずセットで切り替える」ことでした(第20章 5 節)。状態テクスチャは画面と解像度が違うことが あるので、まさにここで効いてきます。

src/lessons/29-feedback/feedback.ts(抜粋)
      // サンプルするのは read。描画先は write。この 2 つが同じテクスチャに
      // なると WebGL は INVALID_OPERATION を返す(本文 1 節)。
      // バインドは必ず drawArrays の直前にやり直す — 役の交代でずれるため
      gl.activeTexture(gl.TEXTURE0);
      gl.bindTexture(gl.TEXTURE_2D, read.texture);
      gl.drawArrays(gl.TRIANGLES, 0, 3);

      const swapped = read;
      read = write;
      write = swapped;
      frame++;
    }

入れ替えは変数の交換だけで、GPU 上のデータは 1 バイトも動きません。「どっちを読むか」を決めているのはbindTextureに渡すハンドルだけだからです。bindTextureをdrawArraysの直前でやり直しているのは意図的で、役の交代のあとに古いバインドが残っていると、 描画先とサンプル元が同じテクスチャになった瞬間ができてしまいます。

そして更新パスと表示パスは分けます。理由は「状態は色ではない」から。 リアクション・ディフュージョンの状態は 2 つの化学物質の濃度で、そのまま RGB に流し込んでも意味のある絵にはなりません。第9章の宿題だった「積み上げた角度」なら、状態は 0〜1 に収まりもしません。状態の形式(何を持つか)と、絵の形式(何色で見せるか)を別々に決められるのが、パスを 2 本に分ける見返りです。表示パスは状態テクスチャを読んで色を作るだけの、ごく短い シェーダーになります。

src/lessons/29-feedback/feedback.ts(抜粋)
    // --- 表示パス: いちばん新しい状態(= read)を画面へ ------------------------
    bindRenderTarget(gl, null);
    gl.useProgram(showProgram);
    gl.uniform1f(showTime, time);
    gl.uniform2f(showResolution, gl.drawingBufferWidth, gl.drawingBufferHeight);
    gl.activeTexture(gl.TEXTURE0);
    gl.bindTexture(gl.TEXTURE_2D, read.texture);
    gl.drawArrays(gl.TRIANGLES, 0, 3);

最後に初期化です。1 ステップ目には「前の状態」がありません。取れる道は 2 つ — 初期化専用のパスを 1 回だけ走らせるか、更新パスの中で分岐するか。この章は後者にしました。前者だとデモ 1 本につきシェーダーが 3 本・プログラムが 3 本になり、そのうえ「初期状態はどう決まるのか」がルールの本体と別のファイルへ離れてしまいます。 ハーネスは更新パスに通し番号u_frameを渡すので、シェーダー側はその 0 番で分岐します。名前はu_frameですが、数えているのは更新ステップです — 1 フレームに複数ステップ進める設定が あるためです(3 節)。だから 1 フレームで 8 ステップ進める設定でも、初期化が走るのは最初の 1 ステップだけです。

src/lessons/29-feedback/01-trail.sim.frag(抜粋)
  if (u_frame == 0) {
    // 1 ステップ目には前の状態が無い。真っ暗から始める
    fragColor = vec4(0.0, 0.0, 0.0, 1.0);
    return;
  }

分岐のコストはテクセルあたり 1 回の整数比較で、初期化のコードがルールのすぐ隣に置ける見返りの ほうが大きいという判断です。u_frame == 0の枝はu_prevを一切読まないので、まだ何も書かれていないテクスチャを読んでしまう心配もありません。

3. 章ローカルのハーネス — 標準ハーネスでは足りない理由

第6章で作ったハーネスの約束は、まとめの最後の行に書いてあります。「使う側はstartFullscreenShader(canvas, fragmentSource)の 1 行。以降のレッスンで書くのはフラグメントシェーダーだけ」。渡せる uniform はu_time/u_resolution/u_mouseの 3 つで、第2部と第4部のすべての章がこの上に建っています。docs/plan.mdの第4部のデモ規約も「追加 uniform の仕組みや独自ハーネスを作らない」と決めていました。

この章は、その前提から 3 か所で外れます。

これは「オプションを 1 つ足せば済む差」ではありません。(a) と (b) はループの構造そのもので、startFullscreenShaderに足すなら、その関数は「1 本を画面へ描くモード」と「2 本で ping-pong するモード」の 2 つを内側に抱えることになります。第6章が置いた抽象化のルールは「読者がまだ中身を知らないコードは、隠さない」でした。分岐だらけの汎用ハーネスは、そのルールと相性が悪い。だからsrc/lib/には手を入れず、章ローカルのハーネスを別に作りました。

docs/plan.mdの抽象化タイムラインでいうと、第23章のgbuffer.ts(G-buffer に何を詰めるかはシーン設計そのもの)や第24章のsdf.glsl(読者が壊して遊ぶ対象)と同じ「あえて共通化しない」判断です。共通化の総まとめは第34章のミニエンジンでまとめてやります。

そのぶん、再利用できるものは全部再利用しています。compileShader/linkProgram(第3章 → 第6章)、createRenderTarget/bindRenderTarget/resizeRenderTarget/deleteRenderTarget(第20章)、そして全画面三角形の頂点シェーダーsrc/lib/fullscreen.vert(第6章)。リサイズ・時間・ポインタの扱いもfullscreen-shader.tsと同じコードです(maxDpr、Math.max(1, Math.floor(...))、u_mouseは描画バッファ px の左下原点)。新しく書いたのは ping-pong の段取りだけです。

src/lessons/29-feedback/feedback.ts(抜粋)
export interface FeedbackOptions {
  /**
   * 状態テクスチャの 1 セルが、描画バッファの何ピクセルにあたるか(既定 1)。
   * 2 以上にすると状態の解像度が画面より粗くなる。ライフゲームのようにセルを
   * 目で数えたい場合と、更新コストを下げたい場合に使う
   */
  cellSize?: number;
  /**
   * 1 フレームあたりの更新パスの回数(既定 1)。
   * 1 より大きくすると 1 フレームで何ステップも進む(リアクション・ディフュージョン)。
   * 1 未満なら数フレームに 1 回になる(0.2 なら 5 フレームに 1 回 = 毎秒 12 世代)
   */
  stepsPerFrame?: number;
  /** devicePixelRatio の上限(既定 2)。負荷を抑えたいときは 1 に下げる */
  maxDpr?: number;
}

オプションは 3 つだけです。cellSizeは状態テクスチャを画面より粗くするためのもの、stepsPerFrameは 1 フレームに何ステップ進めるか。stepsPerFrameは1 未満も許します— 持ち越しの変数 1 つで、数フレームに 1 回だけ進める形にしてあります。どのデモにどの値を入れて、なぜそうしたのかは 5〜7 節のそれぞれで書きます。

src/lessons/29-feedback/feedback.ts(抜粋)
  function createState(width: number, height: number): RenderTarget {
    return createRenderTarget(gl, width, height, {
      depth: false,
      filter: gl.NEAREST,
      internalFormat: stateFormat.internalFormat,
    });
  }

状態テクスチャは深度を持たず(全画面 1 枚を塗るだけなので前後関係が無い)、フィルタは必ずNEARESTです。この 2 つの理由は次の節で扱います。形式の選び方も同じです。

src/lessons/29-feedback/feedback.ts(抜粋)
  // 更新パスの uniform。u_resolution は「状態テクスチャの解像度」
  const simPrev = gl.getUniformLocation(simProgram, 'u_prev');
  const simResolution = gl.getUniformLocation(simProgram, 'u_resolution');
src/lessons/29-feedback/01-trail.show.frag(抜粋)
  ivec2 stateSize = textureSize(u_state, 0);
  ivec2 cell = screenToCell(gl_FragCoord.xy, u_resolution, stateSize);

リサイズすると状態は消えます。第20章 7 節で確かめたとおり、テクスチャもレンダーバッファもサイズは後から変えられないので、resizeRenderTargetは「同じ設定で新しく作ってから古いものを削除する」しかできません。中身は引き継がれません。 だからこのハーネスは、サイズが変わったらframeを 0 に戻して初期化からやり直します。黙って絵が消えると読者は壊れたと思うので、 ここは仕様として明示しておきます — ウィンドウの幅を変えると、育てたリアクション・ディフュージョンの 模様は最初からやり直しになります。

src/lessons/29-feedback/feedback.ts(抜粋)
    read = resizeRenderTarget(gl, read, width, height);
    write = resizeRenderTarget(gl, write, width, height);
    frame = 0;
    stepBudget = 1;

最後に後始末です。第6章のハーネスのstop()は rAF を止めてリスナーを外すだけで、プログラムもテクスチャも解放していません(第2部・第4部の 切替式デモが持っている「後始末は第35章」というコメントがそれです)。この章のハーネスはそこまで書きました。状態テクスチャは画面いっぱいの大きさが 2 枚あるので、デモを切り替えるたびに残していくと 素直に効いてくるからです。作り始めの途中で例外が飛ぶ経路も同じ扱いにします— 拡張が無いのにRGBA16Fを要求してcreateRenderTargetが投げるとき、そこまでに作ったプログラム 2 本と 1 枚目の状態を解放してから投げ直します。stop()で片付ける章が入口に穴を空けているのは筋が通らないからです。

src/lessons/29-feedback/feedback.ts(抜粋)
      deleteRenderTarget(gl, read);
      deleteRenderTarget(gl, write);
      gl.deleteProgram(simProgram);
      gl.deleteProgram(showProgram);

4. 値を正確に運ぶ — float テクスチャと NEAREST、texelFetch

ここからは「状態を壊さずに 1 ステップ運ぶ」ための話です。絵を貼るときには気にしなくてよかった ことが、状態を持たせると全部効いてきます。話題は 3 つ —入れ物(どの形式に置くか)、読み方(NEARESTとtexelFetch)、そして8 ビットに置いたときに起きることです。

入れ物 — float テクスチャと 2 つの拡張

第20章のデモはRGBA8でした。1 成分 8 ビットで、値は 0〜1。フラグメントシェーダーの出力が固定小数点のカラーバッファへ 書かれるとき、OpenGL ES 3.0.6 §4.1.8 はこう定めています。「カラーバッファが固定小数点なら、各成分 は0〜1 にクランプされ、式 2.3 で固定小数点値に変換される」。RGBA8の状態について仕様が保証しているのは、このクランプと、書いた値を挟む 2 つの 1/255 の倍数のどちらかになることだけです。どちらになるかまでは決まっていません(丸めが仕様上どこまで決まっているか、そこに GL 自身のディザ段が絡む話は第27章 8 節の担当です)。

一方RGBA16F/RGBA32Fのような浮動小数点のカラーバッファなら、拡張EXT_color_buffer_floatの仕様が「これらの内部フォーマットへのフラグメントシェーダーの出力はクランプされない」と明記しています。0〜1 の外の値をそのまま置けるということです。 速度も、積み上げた角度も、1 を超える光の量も、素直に入ります。

形式描き込めるかLINEAR で読めるか値の刻み
RGBA8拡張不要(コア)読める(コア)絶対 1/255 固定。0〜1 にクランプされる
RGBA16FEXT_color_buffer_floatかEXT_color_buffer_half_float読める(コア)。ES 3.0 の Table 3.13 でtexture-filterableが付いている相対 2⁻¹¹ ≒ 0.049%。クランプされない
RGBA32FEXT_color_buffer_floatOES_texture_float_linear が必要相対 2⁻²³ ≒ 0.000012%。クランプされない

3 列目が第20章 8 節から一歩踏み込んだところです。「描けるか」と「補間して読めるか」は、まったく別の拡張で決まります。ES 3.0.6 の Table 3.13 は、内部フォーマットごとに Color-renderable と Texture-filterable の 2 列を持っていて、RGBA16Fはfilterable にだけ印が付き、RGBA32Fはどちらにも付いていません。拡張OES_texture_float_linearの仕様は、その差をこう説明しています。「OpenGL ES 3.0 は、必須の 16 ビット浮動小数点フォーマットが filterable であることを要求するが、32 ビットのフォーマットについてはフィルタリングをサポートしない」。そして「OpenGL ES 3.0 以降に対して実装される場合、この拡張は既存の 32 ビット浮動小数点フォーマットを texture-filterable にする(新しいフォーマットは追加しない)」。WebGL 2.0 仕様の「Extension Functionality Moved to Core」の一覧にも、OES_texture_half_float_linearは入っているがOES_texture_float_linear は入っていないと、括弧つきで はっきり書かれています。

そこでハーネスは、使える形式を上から順に試して落としていく形にしました。第20章 8 節の「EXT_color_buffer_float→ 無ければEXT_color_buffer_half_float→ それも無ければRGBA8」という順序をそのまま実装したものです。第一希望をRGBA32FではなくRGBA16Fにしているのは、上の表の 2 行目の理由です — 帯域が半分で、補間して読むのもコアの機能で、 リアクション・ディフュージョンやトレイルに要る精度は十分あります。

src/lessons/29-feedback/feedback.ts(抜粋)
  if (gl.getExtension('EXT_color_buffer_float')) {
    return { internalFormat: gl.RGBA16F, label: 'RGBA16F (EXT_color_buffer_float)' };
  }
  // 32 ビットに描けない端末のための拡張。RGBA16F への描画は実装に必須(第20章 8 節)
  if (gl.getExtension('EXT_color_buffer_half_float')) {
    return { internalFormat: gl.RGBA16F, label: 'RGBA16F (EXT_color_buffer_half_float)' };
  }

どれになったかはデモの読み出し行に出しています。また、拡張が無いのにRGBA16Fを要求してしまった場合はcreateRenderTargetがFRAMEBUFFER_INCOMPLETE_ATTACHMENTというステータス名付きで例外を投げるので(第20章 4 節)、main.tsはそれを捕まえて読み出し行にそのまま表示します。デモが真っ暗なまま何も言わない、という いちばん困る失敗の仕方を避けるためです。

配列として読む — NEAREST と texelFetch

ここまでで「NEARESTにしておくのが無難」という結論は出ましたが、状態テクスチャでNEARESTが要るのはもっと手前の理由からです。補間されると、読んでいる値が「隣のセルとの混ぜ物」になる。ライフゲームで近傍を数えるとき、あるセルの値が 0.7 だったらどう数えればいいのでしょうか。 リアクション・ディフュージョンのラプラシアンは、9 個のセルの値に決まった重みを掛ける計算です — その 9 個が勝手に混ぜられていたら、計算しているのは自分の書いた式ではありません。状態は「絵」ではなく「配列」なので、テクセルの値がそのまま返ってこなければ困るのです。

そして配列として読むための関数がtexelFetchです。第16章 7 節で「第29章のフィードバック(前フレームのバッファをピクセル単位で読み、次の フレームを計算する)で本領を発揮します」と予告した、あれです。同節でまとめた性質をもう一度:

texelFetch はフィルタ段を通らないので、上の「補間されて混ざる」 問題は関数を選ぶだけで消えます。この章の 3 本のデモは、更新パスも表示パスも、すべてtexelFetchで読んでいます。ただしフィルタ設定がテクスチャを不完全にしてしまう場合は別です — ES 3.0.6 §2.12.9.2 は「テクスチャ画像は点サンプリングされる(フィルタは行われない)が、テクスチャアクセスの残りの段は依然として適用される」と書いていて、未定義になる条件の 1 つに「アクセスされるテクスチャが完全でない」を挙げています (この節は §3.9.2.1 によりフラグメントシェーダーにも適用されます)。つまり上の落とし穴のとおり、 filterable でない形式にLINEARを掛けてしまえばtexelFetchでも(0, 0, 0, 1)が返ります。フィルタ関数は選べても、テクスチャの完備性からは逃げられません。

代わりに端の扱いは自分で書くことになります — ラップモードが効かないので、トーラス(上下左右がつながる)にしたいなら折り返しを手で書きます。

src/lessons/29-feedback/grid.glsl(抜粋)
ivec2 wrapCell(ivec2 cell, ivec2 size) {
  return (cell + size) % size;
}

sizeを足してから%を取っているのには理由があります。GLSL ES 3.00 §5.9 は剰余についてこう書いています。「両方の 被演算子が非負なら、剰余は非負になる。一方または両方の被演算子が負のとき、結果は未定義」。近傍として見るのは上下左右斜めの 1 セルだけなのでcellは-1以上、sizeは 1 以上。先に足しておけば必ず非負になります。端を「壁」にしたいならclamp(cell, ivec2(0), size - 1)に差し替えるだけで、ライフゲームもリアクション・ディフュージョンも見た目が変わります (端で模様が切れるか、反対側へ回り込むか)。

texelFetchにはもう 1 つ、地味だがありがたい性質があります。ピクセル中心の 0.5 を忘れられない。第7章の aside で「gl_FragCoord.xyに入っているのはピクセルの中心の座標です。一番左下のピクセルなら (0.5, 0.5) …… 後の章でピクセルを 1 個単位で数える処理(第29章のフィードバックなど)をするときに効いてくる」 と書いた回収がここです。

ピクセル中心と 2 つの読み方(説明用)
// 説明用: texture() で前フレームを読む書き方と、texelFetch との対応
vec2 uv = (gl_FragCoord.xy) / u_resolution;          // 中心を指す(第7章)
vec2 bad = (floor(gl_FragCoord.xy)) / u_resolution;  // 半テクセル手前を指す
vec3 a = texture(u_prev, uv).rgb;                    // フィルタとラップを通る
vec3 b = texelFetch(u_prev, ivec2(gl_FragCoord.xy), 0).rgb; // どちらも通らない

texture()で前フレームを読むなら、渡す UV はgl_FragCoord.xy / u_resolution— gl_FragCoordがすでに中心を指しているので、これでちょうどテクセルの中心に当たります。ところが「整数のピクセル 番号が欲しい」と考えてfloorを挟むと、UV は半テクセル手前を指し、LINEARなら隣と半々に混ざり、NEARESTでも運が悪ければ隣のテクセルが返ります。texelFetchは座標を整数のテクセル番号として受け取るので、この問題そのものが存在しません。ivec2(gl_FragCoord.xy)は 0.5 を切り捨てて、そのピクセルの番号をぴったり返します。

8 ビットで状態を持つと何が起きるか

RGBA8の刻みは絶対の 1/255 固定です。半精度 float の刻みが相対で 2⁻¹¹(0.049%)なのと対照的で、値が小さくなるほど相対的に粗くなるということです。第27章 1 節が「暗部の刻みは明部の 29.3 分の 1」「リニアのまま 8 ビットなら刻みは一定の 1/255」として数値付きで見た性質を、この章では状態の側から受けることになります。 刻みより小さい変化は、書いても戻ってきません。トレイルの減衰がそれで途中で止まる話は 5 節で実測とともに見ます。ここでは、もっと厄介なリアクション・ディフュージョンのほうを見ておきます。

リアクション・ディフュージョンの 1 ステップの変化量|ΔV|は、最初の数ステップこそ6 × 10⁻²と大きいものの、模様が落ち着くと2 × 10⁻³前後まで下がります。8 ビットの刻みの半分0.5/255 = 0.00196を下回るセルの割合を数えると、10 ステップ目で 94%、100 ステップ目で 89%、1200 ステップ目で99.9%でした(F = .055/k = .062、96 × 96)。ほとんどのセルで「1 ステップぶんの変化が刻みに届かない」状態です。

94% → 89% といったん下がるのは、数えているものが途中で入れ替わるからです。10 ステップ目の 94% のうち 37 ポイントは「まだ V がまったく動いていない」セルで、|ΔV|がちょうど 0 — 変化が小さいのではなく変化が始まっていないだけです。100 ステップ目にはこの 0 のセルが 1 つも残らず、全面が動き出すのでいったん割合が下がり、そのあと 模様が落ち着くにつれて 99.9% まで上がっていきます。

ではどうなるか。模様が消えるわけではありませんでした。8 ビットの量子化を挟んだ同じ計算を CPU で回すと、模様はちゃんと出ます — ただし float とは別の模様になります。同じ(F, k)で 1200 ステップ後にVが 0.15 を超えたセルは、float で 57.5%、8 ビットで 23.6%。一方、デモの中央あたりの(F = .038, k = .058)では 78.3% と 79.6% でほとんど同じでした。つまり8 ビットで回した結果は「書いた式の解」ではなく、量子化という別のルールが混ざった系の解で、パラメータによって元の解に近くも遠くもなります。「動いているから合っている」とは言えない、 というのがここでの教訓です。デモの読み出し行に状態テクスチャの形式を出しているのは、この違いが 「端末による見え方の違い」として現れうるからです。なお、階調の刻みが見えてしまう問題を意図的に散らす技法(バンディングとディザリング)は第27章 8 節の担当なので、ここでは触れません。

5. トレイルと残像

道具が揃ったので、いちばん簡単な作例から。トレイル(残像)の更新規則は 1 行です。

src/lessons/29-feedback/01-trail.sim.frag(抜粋)
  // ① 前の状態を少しだけ暗くする
  vec3 next = prev * DECAY;

前の状態を少しだけ暗くして、そこに新しい光を足す。それだけで軌跡が伸びます。DECAYと見た目の関係は指数減衰そのままで、値がe分の 1(約 37%)になるまでのステップ数は-1 / ln(DECAY)です。0.955なら 21.7 ステップ ≒ 0.36 秒、0.99なら 99.5 ステップ ≒ 1.7 秒。「尾の長さ」は秒数で設計して、そこから DECAY を決めるのが実用的です。

8 ビットだと、減衰が途中で止まる

4 節の「刻みより小さい変化は書いても戻ってこない」を、この 1 行の更新で数字にします。RGBA8の状態でDECAY = 0.955を回すと、1 ステップの変化量が刻み 1/255 の半分より小さくなった時点で、値が自分自身に戻ります。丸めが最近傍なら、その条件はv × (1 − DECAY) ≤ 0.5、つまりv ≤ 0.5 / 0.045 = 11.1。11/255 で減衰が止まり、そこから先は永遠に消えません。実際に 255 から回してみると 66 ステップ目で 11 に到達し、そのまま動かなくなります(閉じた式と 実測が一致)。

そして 11/255 は、リニアの量としては 0.043 です。これを画面へ出すために sRGB へエンコードすると(第27章 2 節)0.230 = 59/255になります。リニアでは「ほぼ 0」に見える残りかすが、画面では 23% のはっきりした灰色として残るのです。「軌跡がいつまでも薄く残って消えない」という症状の正体はこれです。

ではRGBA16Fなら止まらないのか — こちらも止まります。半精度 float の刻みは相対で 2⁻¹¹(0.049%)なので 1 ステップの変化 4.5% に対して 92 倍の余裕がありますが、値が非正規化数の領域(刻みが 2⁻²⁴ の絶対値になる)まで落ちれば同じことが 起きます。停止の条件は0.045 v < 2⁻²⁵、つまりv < 6.6 × 10⁻⁷。半精度の丸めを CPU で再現すると、307 ステップ目で 6.6 × 10⁻⁷ に落ちてそこで止まりました。ただしその手前、191 ステップ目(値 1.5 × 10⁻⁴)で sRGB へ直した値が 0/255 に丸まります。8 ビットも 16F も止まる。違うのは、止まる値が見えるかどうかです。

110⁻¹10⁻²10⁻³10⁻⁴10⁻⁵10⁻⁶10⁻⁷0100200300更新ステップここより下は sRGB で 0/255 = 目に見えないRGBA16F — 307 ステップで 6.6 × 10⁻⁷ に止まるRGBA8 — 66 ステップで 11/255 に張り付く(画面では 59/255 の灰色)縦軸は状態に入っているリニアの値(対数)。CPU で 1.0 から回した実測
DECAY = 0.955の減衰。どちらの形式でも止まりますが、RGBA8が止まる値は破線よりずっと上 = 画面に灰色として残り、RGBA16Fが止まる値は破線よりずっと下 = 目には完全に消えます。

ここで第2章の宿題が返ってきます。第2章のコンテキスト属性の表で、preserveDrawingBufferの行にこう書きました。「表示(合成)後も描画バッファの中身を保持するか。既定では『描いた フレームは次には残らない』前提で毎フレーム描き直す。軌跡を残す表現やスクリーンショットには別の手を使う(第29章・第36章)」。その「別の手」がこれです。

preserveDrawingBuffer: trueにすれば、たしかに前のフレームが残るので、その上に描き足せば軌跡になります。しかしこの章の方法の ほうが素直です。理由を並べます。

表示パスは短いものです。状態に入っているのは「光の量」で、1 を超えることもあります。背景色をリニア空間で足してから、最後の 1 行で sRGB へエンコードして画面に出します。

1 を超えた値は、どうなるのでしょうか。ブラシを止めて同じ場所に光を足し続けると、値はBRUSH_GAIN / (1 − DECAY) = 0.6 / 0.045 = 13.3まで上がります。この章はそれを畳まずにlinearToSrgbへ渡すので、光の芯は伝達関数の中のclampで白に飽和します。階調を残したいなら、第27章 5 節のトーンマッピングを手前に挟みます(順序は露出 → トーンマッピング → sRGB エンコード。逆にするとlinearToSrgbのclampが 1 を超えた情報を先に捨ててしまいます)。この章で入れていないのは、焦点を 「状態を運ぶこと」に絞るためです。

src/lessons/29-feedback/01-trail.show.frag(抜粋)
  // 足し算はリニア空間で(第27章 4 節)。背景も光もリニアの量として扱う
  vec3 color = srgbToLinear(BACKGROUND) + glow;

  fragColor = vec4(linearToSrgb(color), 1.0);

srgbToLinear/linearToSrgbは第27章のcolor.glslからの複製です(内容は同一)。変換の体系そのもの — 正確な伝達関数とpow(2.2)近似の差、どこで変換するか、なぜ足し算はリニア空間なのか — は第27章 2〜4 節が担当です。 この章で押さえておきたいのは上で見た「11/255 が 59/255 に見える」の一点だけで、そこが「状態の刻み」と「見た目の刻み」が別物であることの実例になっています。

src/lessons/29-feedback/01-trail.sim.frag
#version 300 es

// 第29章 デモ1 更新パス: トレイル(残像)。
// 前の状態を少しだけ暗くして、そこに新しい光を足す。それだけ。
//   next = prev * DECAY + brush
// 隣のセルを読まないので、この更新パスだけは近傍もラップも要らない。
//
// ポインタ: 光る点がついてくる。動かすと軌跡が残る(未操作のあいだは画面中央)。
// 自動で動く点(リサージュ図形)も 1 つ走っていて、こちらは常に軌跡を描く。

precision highp float;
// u_frame を 32 ビットで受けるため。フラグメントシェーダーの int の既定精度は mediump で、
// mediump の符号付き整数の保証は [-2^15, 2^15 - 1] しかない(GLSL ES 3.00 §4.5.1)。
// 毎秒 60 ステップだと 9 分ほどで越えてしまう。同じ構図の話は第25章 3 節
precision highp int;

// 状態テクスチャは float。値が 0〜1 に収まらないので highp を明示する(第21章 6 節)。
// 既定は lowp sampler2D で、texture() / texelFetch() の戻り値もその精度になる
uniform highp sampler2D u_prev;
// この 3 つは「状態テクスチャの」解像度と座標系。画面の解像度ではない(本文 4 節)
uniform vec2 u_resolution;
uniform vec2 u_mouse;
uniform float u_time;
// 更新パスの通し番号。0 なら前の状態が無いので、初期化に使う
uniform int u_frame;

out vec4 fragColor;

// ---- 書き換えて試すための定数 ----------------------------------------
// 1 ステップで前の状態に掛ける倍率。1.0 に近いほど尾が長い
const float DECAY = 0.955;
// ブラシの半径(状態テクスチャのセル)
const float BRUSH_RADIUS = 14.0;
// ブラシが 1 ステップで足す量。ブラシを止めたときの明るさは
// BRUSH_GAIN / (1 - DECAY) = 13.3 まで上がり、表示側の clamp で白く飛ぶ
const float BRUSH_GAIN = 0.6;
// 自動で動く点の速さ
const float ORBIT_SPEED = 0.55;
// ブラシの色。sRGB のコード値ではなく「光の量」の比として、リニアで書いている
// (第27章 3 節。単位のない 0〜1 はリニア値として扱うと決める)
const vec3 ORBIT_COLOR = vec3(0.30, 0.60, 1.00);
const vec3 POINTER_COLOR = vec3(1.00, 0.52, 0.20);
// ----------------------------------------------------------------------

/** 中心 center に、ふちがぼける光を置く。戻り値は「足す量」 */
vec3 brush(vec2 pixel, vec2 center, vec3 color) {
  float d = length(pixel - center) / BRUSH_RADIUS;
  return color * BRUSH_GAIN * exp(-4.0 * d * d);
}

void main() {
  if (u_frame == 0) {
    // 1 ステップ目には前の状態が無い。真っ暗から始める
    fragColor = vec4(0.0, 0.0, 0.0, 1.0);
    return;
  }

  // 更新パスの viewport は状態テクスチャそのものなので、
  // gl_FragCoord はそのままセルの座標になる。整数に落として texelFetch へ
  ivec2 cell = ivec2(gl_FragCoord.xy);
  vec3 prev = texelFetch(u_prev, cell, 0).rgb;

  // ① 前の状態を少しだけ暗くする
  vec3 next = prev * DECAY;

  // ② 新しい光を足す
  vec2 orbit = 0.5 * u_resolution
    + 0.34 * u_resolution * vec2(sin(u_time * ORBIT_SPEED), sin(u_time * ORBIT_SPEED * 1.37 + 1.1));
  next += brush(gl_FragCoord.xy, orbit, ORBIT_COLOR);
  next += brush(gl_FragCoord.xy, u_mouse, POINTER_COLOR);

  fragColor = vec4(next, 1.0);
}

6. ライフゲーム

次は離散状態です。ライフゲーム (Conway's Game of Life) のルールは、Martin Gardner が Scientific American の連載コラム「Mathematical Games」で紹介したもの(Scientific American 223(4), October 1970, pp.120-123。DOI 10.1038/scientificamerican1070-120)で、格子の各セルについて 次の 1 世代を近傍 8 セルの生存数だけで決めます。

「近傍 8 セルを読む」— 第1章の制約が禁じていた、まさにそれです。パスを分けた今ならできます。

src/lessons/29-feedback/02-life.sim.frag(抜粋)
  // 近傍 8 セルを数える。texelFetch なのでフィルタも正規化座標も経由しない
  int neighbours = 0;
  for (int j = -1; j <= 1; j++) {
    for (int i = -1; i <= 1; i++) {
      if (i == 0 && j == 0) {
        continue; // 自分は数えない
      }
      float n = texelFetch(u_prev, wrapCell(cell + ivec2(i, j), size), 0).r;
      neighbours += n > 0.5 ? 1 : 0;
    }
  }
src/lessons/29-feedback/02-life.sim.frag(抜粋)
  bool alive = texelFetch(u_prev, cell, 0).r > 0.5;
  bool born = neighbours == BIRTH;
  bool survives = alive && neighbours >= SURVIVE_MIN && neighbours <= SURVIVE_MAX;

ルールは冒頭のconstに出してあるので、SURVIVE_MIN/SURVIVE_MAX/BIRTHを書き換えれば別の細胞オートマトンになります(標準のライフゲームはB3/S23と表記されます)。この式が本当にライフゲームなのかは、JavaScript に同じ式を移して確かめました。3 つ横に並んだ「ブリンカー」は 2 ステップで元の形に戻り、5 セルの「グライダー」は 4 ステップでちょうど (1, 1) だけ平行移動します — どちらも一致しました。

1 ビットの状態を float テクスチャに入れるのは、一見もったいない使い方です。R8でもR8UIでも足りるはずで、実際そのほうが帯域は少なくて済みます。それでもRGBA16Fに揃えているのは、この章の 3 本のデモが同じハーネスを共有していて、形式を選ぶ分岐をハーネスの 外へ出したくなかったからです。ただし読むときには気をつけます。float に入れた「0 か 1」を読み戻して比較するので、コードは== 1.0ではなく> 0.5で判定しています。0 と 1 は float でも 16 ビット float でも正確に表せますが、しきい値で書いて おけば、途中に補間が挟まる書き方に変えても壊れません。

表示パスでは、状態のr成分(今の世代)とg成分(1 つ前の世代)を両方読んで、「たったいま死んだセル」を薄く光らせています。生きているセルは 白、余韻は青灰、死んでいるセルは背景色。生死そのものは 0 か 1 しかないので、mixは端点をそのまま返します。そしてsrgbToLinearとlinearToSrgbは互いの逆関数なので、定数に書いたLIVEとDEADはsRGB 値のまま画面に出ます。色をリニア空間で組み立てて最後に sRGB へ直すこの書き方が、白と背景色そのものの見え方を変えてしまう心配はない、ということです。効くのは、 セルの境目の線と余韻の中間色 — つまりmixが端点以外を返す場所だけです。

格子はcellSize = 10にして、1 セルが 10 ピクセルに見えるようにしました。stepsPerFrameは1 / 6、つまり毎秒 10 世代です。毎フレーム(毎秒 60 世代)進めると、どのセルがどう 動いたのかが目で追えません。「速く回せる」ことと「見て分かる」ことは別なので、ここは意図的に遅くしています。3 節のハーネスがstepsPerFrameに 1 未満を許しているのは、この 1 本のためです。

src/lessons/29-feedback/02-life.sim.frag
#version 300 es

// 第29章 デモ2 更新パス: ライフゲーム (Conway's Game of Life)。
// ルールが世に出た記事: Martin Gardner, "Mathematical Games",
//   Scientific American 223(4), October 1970, pp.120-123.
//   DOI 10.1038/scientificamerican1070-120
//
// 状態は 1 セルにつき「生きているか」の 1 ビットだけ。それを float テクスチャの
// r 成分に 0.0 / 1.0 で入れている。g 成分には 1 つ前の世代を残しておき、
// 表示パスで「たったいま死んだセル」を薄く光らせるのに使う。

precision highp float;
// hash.glsl のため(理由は第25章 3 節)。理由はもう 1 つあって、下の u_frame も
// mediump では足りない(符号付き整数の保証は 32767 まで)。この行を消すと両方壊れる
precision highp int;

// float テクスチャを読むサンプラには highp を明示する(第21章 6 節)。既定は lowp。
// ライフゲームの状態は 0 か 1 だけなので lowp でも足りるが、章の 6 本で揃えている。
// 精度が本当に効くのは 03-reaction.sim.frag のほう
uniform highp sampler2D u_prev;
uniform vec2 u_resolution; // 状態テクスチャ = 格子の解像度
uniform int u_frame;

out vec4 fragColor;

#include "hash.glsl"
#include "grid.glsl"

// ---- 書き換えて試すための定数 ----------------------------------------
// 初期配置で生きているセルの割合
const float SEED_DENSITY = 0.28;
// ルール。標準のライフゲームは「生存 2〜3・誕生 3」(B3/S23)
const int SURVIVE_MIN = 2;
const int SURVIVE_MAX = 3;
const int BIRTH = 3;
// ----------------------------------------------------------------------

void main() {
  ivec2 size = ivec2(u_resolution);
  ivec2 cell = ivec2(gl_FragCoord.xy);

  if (u_frame == 0) {
    // 初期配置。セルの番地から直接乱数を引く(第25章のハッシュ)。
    // 前の状態を一切見ないので、1 ステップ目でも問題なく決まる
    float alive = hash21(cell) < SEED_DENSITY ? 1.0 : 0.0;
    fragColor = vec4(alive, 0.0, 0.0, 1.0);
    return;
  }

  // 近傍 8 セルを数える。texelFetch なのでフィルタも正規化座標も経由しない
  int neighbours = 0;
  for (int j = -1; j <= 1; j++) {
    for (int i = -1; i <= 1; i++) {
      if (i == 0 && j == 0) {
        continue; // 自分は数えない
      }
      float n = texelFetch(u_prev, wrapCell(cell + ivec2(i, j), size), 0).r;
      neighbours += n > 0.5 ? 1 : 0;
    }
  }

  bool alive = texelFetch(u_prev, cell, 0).r > 0.5;
  bool born = neighbours == BIRTH;
  bool survives = alive && neighbours >= SURVIVE_MIN && neighbours <= SURVIVE_MAX;

  // r = 新しい世代 / g = 1 つ前の世代(表示で余韻として使う)
  fragColor = vec4((born || survives) ? 1.0 : 0.0, alive ? 1.0 : 0.0, 0.0, 1.0);
}

7. リアクション・ディフュージョン

最後は連続量です。反応拡散系 (reaction-diffusion system)は、 複数の物質が反応しながら拡散する系で、単純な規則から縞・斑点・迷路のような模様が出てきます。 この章が使うのは Gray-Scott モデルで、John E. Pearson がComplex Patterns in a Simple System(Science 261(5118), 1993, pp.189-192。DOI10.1126/science.261.5118.189。本文は arXiv:patt-sol/9304003で読めます)で系統的に調べた形です。同論文の式 (2) は次のとおりです。

∂U/∂t = Du ∇²U − U V² + F (1 − U)

∂V/∂t = Dv ∇²V + U V² − (F + k) V

UとVが 2 つの物質の濃度です。反応はU + 2V → 3VとV → P(P は不活性な生成物)の 2 本で、FはUを供給する速さ、kは 2 番目の反応の速さです。U V²の項が両方の式に符号を変えて現れているのが「U が 1 つ食われて V が 1 つ増える」に対応します。 論文の値はDu = 2 × 10⁻⁵、Dv = 10⁻⁵、系のサイズは 2.5 × 2.5、境界は周期的、 格子は 256 × 256、時間刻みは 1 です。

記号を 2 つだけ足しておきます。∂U/∂tは「U が時間あたりどれだけ増えるか」です。∇²はラプラシアン (Laplacian)と呼ばれる量で、乱暴に言えば「周りの平均から自分の値を引いたもの」 — 周りより自分が低ければ正、高ければ負になります(勾配∇dは第24章 1 節で出てきましたが、∇²はここが初出です)。だからDu ∇²Uを足すと、値は濃いところから薄いところへ流れます。これが拡散 (diffusion)です。次に出てくる 3×3 の重みが「中心だけ負・周囲は正」になっているのは、まさにこの引き算を しているからです。

これを GPU に載せるには 2 つの離散化が要ります。時間は前進オイラー法 — 「いまの値 + 時間刻み × 変化率」で 1 ステップ進めるだけです。空間、つまり ラプラシアン∇²は近傍の重み付き和で近似します。この章は 3×3 の重みを使います。

0.050.20.050.2−10.20.050.20.05近傍 3×3 の重み周囲 8 マスの合計 = 0.05 × 4 + 0.2 × 4 = 1.0中心は −1。だから重み付き和は(周囲の重み付き平均) − (自分の値)全体が平らなら和は 0 = 何も流れない。自分がへこんでいれば正 = 流れ込む斜めを上下左右の 1/4 に軽くしたのが「9 点」側の工夫
3×3 のラプラシアンの重み。周囲の合計 1.0 と中心の −1 が打ち消し合うのが 本質で、「周りとの差」だけを取り出しています。この形が03-reaction.sim.fragのifの羅列に対応します。
src/lessons/29-feedback/03-reaction.sim.frag(抜粋)
vec2 laplacian(ivec2 cell, ivec2 size) {
  vec2 sum = vec2(0.0);
  for (int j = -1; j <= 1; j++) {
    for (int i = -1; i <= 1; i++) {
      float weight;
      if (i == 0 && j == 0) {
        weight = -1.0;
      } else if (i == 0 || j == 0) {
        weight = 0.2;
      } else {
        weight = 0.05;
      }
      sum += weight * texelFetch(u_prev, wrapCell(cell + ivec2(i, j), size), 0).rg;
    }
  }
  return sum;
}

中心 −1・上下左右 0.2・斜め 0.05 という重みは、Karl Sims がReaction-Diffusion Tutorialで使っているものです。この 9 個の数は、標準的な 9 点ラプラシアン∇² ≈ (1/6h²) × [[1,4,1],[4,−20,4],[1,4,1]]の係数行列だけを 20 で割ったものです。hは格子の刻みで、前係数1/(6h²)は落としています。つまりこの重み付き和は∇²の(3/10)h²倍にあたり、逆に言えば∇² ≈ (10/3) × (重み付き和) / h²です(次元でも合います — 重みは無次元なので重み付き和の単位はUと同じ、∇²UはU÷ 長さ²。両者をつなぐ係数は長さ²の次元を持たなければなりません)。実際に確かめると、既知の関数について(10/3) × (重み付き和) / h²と解析的なラプラシアンの相対誤差はh = 0.1で 1.3 × 10⁻³、hを半分にするごとに 1/4 になりました(sin(1.3x) · exp(0.4y)を 2000 点で測った最大の相対誤差。2 次の収束)。落とした前係数のぶんは、そのままDU/DVが吸います。だから拡散係数の絶対値は格子の刻みと時間刻みに依存する量で、論文の2 × 10⁻⁵をそのまま書いても動きません。

換算してみます。論文の設定(サイズ 2.5・256 格子・時間刻み 1)ならh = 2.5/256なので、この 3×3 の重みに対する 1 ステップの係数は1 × 2×10⁻⁵ × (10/3) / h² = 0.699、V側は0.350です。デモが使っているのは Karl Sims のDU = 1.0/DV = 0.5で、換算値の約 1.43 倍にあたります。一致しているのは比の 2 : 1 のほうで、これは論文のDu / Dvと同じです。絶対値のほうは「模様の大きさが格子何セル分になるか」を決めるつまみだと理解しておけば 十分です。

src/lessons/29-feedback/03-reaction.sim.frag(抜粋)
  // U + 2V → 3V の反応。U を 1 つ食って V が 1 つ増える
  float reaction = u * v * v;
  float nextU = u + DT * (DU * lap.x - reaction + f * (1.0 - u));
  float nextV = v + DT * (DV * lap.y + reaction - (f + k) * v);

1 フレームに 8 回更新しています。模様ができるまでに 1000 ステップ以上かかるので、毎フレーム 1 回では 17 秒待つことになります。8 回なら 2 秒台です。そのぶん更新するテクセルの数が 8 倍になるので、cellSizeを 3 にして状態の解像度を落としています。この 2 つはセットで決めるつまみです。

src/lessons/29-feedback/main.ts(抜粋)
    // 模様ができるまでに 1000 ステップ以上かかるので、1 フレームで 8 回進める。
    // 1 セル = 3 ピクセルにしているのは、更新の回数が 8 倍あるぶんの節約
    options: { cellSize: 3, stepsPerFrame: 8 },

初期配置も論文に倣っています。全体を(U, V) = (1, 0)にしてから一部を(0.5, 0.25)に乱し、さらに ±1% の乱数で正方形の対称性を崩す。乱数は第25章のハッシュhash21をそのまま複製して使いました(src/lessons/29-feedback/hash.glsl。内容は第25章のnoise.glslと同一)。

src/lessons/29-feedback/03-reaction.sim.frag(抜粋)
    // Pearson の初期条件と同じ組み立て: 全体を (U, V) = (1, 0) にしてから、
    // 一部を (0.5, 0.25) に乱し、さらに ±1% の乱数で正方形の対称性を崩す
    bool seeded = hash21(cell / SEED_BLOCK) < SEED_RATIO;
    float jitter = 1.0 + 0.01 * (hash21(cell) - 0.5) * 2.0;

論文は中央の 20 × 20 だけを乱していますが、デモは 8 × 8 のブロック単位で全面に 8% ばらまいています。中央の 1 か所だけだと、模様が画面全体に広がるまでに論文の記述どおり 1 万〜2 万ステップかかるからです(毎フレーム 8 回なら 21〜42 秒)。

Fとkはポインタで動かします。この 2 つで模様がまるごと変わりますが、模様が出るのは (k, F) 平面のごく狭い三日月形の領域だけです。Karl Sims の記事にある地図はkを .045〜.07、Fを .01〜.1 の範囲で張っていて、その中の細い帯にだけ模様が現れます。素直に長方形を割り当てると ポインタのほとんどの位置で何も起きないので、デモは帯に沿った斜めの割り当てに してあります(左右でk、上下でその帯の中の位置)。CPU で 5 × 5 の格子に分けて 1200 ステップずつ試したところ、25 点のうち 17 点で模様が出ました。残りは一様な状態に落ち着く領域です。

それでも死ぬ位置は残るので、格子の 1 ブロックだけVを絶やさない「種」として固定してあります。模様が消える領域へポインタを動かしてしまっても、 戻ってくればそこから生え直します(ハーネスのリサイズと違って、状態は消えません)。

色はVの濃度を 3 色の階調に写しただけです。CPU で 128 × 128 を 2000 ステップ、デモが割り当てている(k, F)の帯を 9 × 5 の格子で掃いて測ると、Vの最大は場所によって 0.24〜0.47 まで振れます(模様が立たず一様な状態に落ちる 2 点を除く)。表示は0.43 × 2.4 ≒ 1.03を目安に 2.4 倍しているので、0.43 より上へ振れる帯では表示側のclampで白に潰れます。絵が壊れるわけではありませんが、「いちばん明るいところが白で飽和している帯がある」ことは 知っておいてください。パレットの設計そのもの (余弦パレットなど)は第27章 7 節の担当なので、ここでは踏み込みません。

src/lessons/29-feedback/03-reaction.sim.frag
#version 300 es

// 第29章 デモ3 更新パス: リアクション・ディフュージョン(Gray-Scott モデル)。
//
// 元の式(出典: John E. Pearson, "Complex Patterns in a Simple System",
//   Science 261(5118), 1993, pp.189-192. DOI 10.1126/science.261.5118.189。
//   本文は arXiv:patt-sol/9304003 で読める):
//
//   ∂U/∂t = Du ∇²U − U V² + F (1 − U)
//   ∂V/∂t = Dv ∇²V + U V² − (F + k) V
//
// これを前進オイラー法で 1 ステップずつ進める。∇² は下の 3×3 の重みで近似する。
// U と V の 2 つの濃度を、状態テクスチャの r 成分と g 成分に入れている。
//
// ポインタ: 左右で k、上下で F。この 2 つで模様がまるごと変わる。

precision highp float;
// hash.glsl のため(理由は第25章 3 節)。理由はもう 1 つあって、下の u_frame も
// mediump では足りない(符号付き整数の保証は 32767 まで)。この行を消すと両方壊れる
precision highp int;

// float テクスチャを読むサンプラには highp を明示する(第21章 6 節)。既定は lowp で、
// 絶対精度の保証は 2⁻⁸ = 3.9e-3 しかない。模様が落ち着いたあとの 1 ステップの変化は
// |ΔV| ≈ 2e-3 でそれより細かいので、状態が 0〜1 に収まっていても lowp では足りない
uniform highp sampler2D u_prev;
uniform vec2 u_resolution; // 状態テクスチャ = 格子の解像度
uniform vec2 u_mouse;      // 状態テクスチャの座標系
uniform int u_frame;

out vec4 fragColor;

#include "hash.glsl"
#include "grid.glsl"

// ---- 書き換えて試すための定数 ----------------------------------------
// U と V の拡散の速さ。比が 2:1 なのは Pearson の Du = 2e-5 / Dv = 1e-5 と同じ。
// 絶対値のほうは格子の刻みと時間刻みに依存する量で、この値は Karl Sims
// (karlsims.com/rd.html) の実装が使っているもの
const float DU = 1.0;
const float DV = 0.5;
// 1 ステップの時間刻み。DT * DU が 1.25 を超えると発散する(本文 7 節)
const float DT = 1.0;
// 初期配置: 一辺 SEED_BLOCK セルのブロック単位で、SEED_RATIO の割合に種を置く。
// ブロックが小さすぎると種は育たずに消える(本文 7 節の実測)
const int SEED_BLOCK = 8;
const float SEED_RATIO = 0.08;
// 種を絶やさないための固定のブロック(番地)。模様が死ぬパラメータへ行っても、
// 戻ってくればここから生え直す
const int PIN_X = 3;
const int PIN_Y = 3;
// ポインタが動かす範囲。Karl Sims の (k, F) の地図が k = .045〜.07 /
// F = .01〜.1 を張っているうちの、模様が出る三日月形に沿った帯
const float K_MIN = 0.052;
const float K_MAX = 0.064;
const float F_BASE = 0.020;
const float F_SLOPE = 3.0;
const float F_SPREAD = 0.020;
// ----------------------------------------------------------------------

/**
 * ラプラシアン ∇² の 3×3 近似。中心 −1・上下左右 0.2・斜め 0.05。
 * これは標準的な 9 点ラプラシアン ∇² ≈ (1/6h²)·[[1,4,1],[4,−20,4],[1,4,1]] の
 * 係数行列だけを 20 で割ったもので、前係数 1/(6h²) は落としている。だから
 * この重み付き和は ∇² の (3/10)h² 倍 — 逆に言えば ∇² ≈ (10/3)·(重み付き和)/h²。
 * 落とした前係数のぶんは DU / DV が吸う(本文 7 節)。
 * U と V を同時に計算するので、戻り値は vec2(∇²U, ∇²V)。
 */
vec2 laplacian(ivec2 cell, ivec2 size) {
  vec2 sum = vec2(0.0);
  for (int j = -1; j <= 1; j++) {
    for (int i = -1; i <= 1; i++) {
      float weight;
      if (i == 0 && j == 0) {
        weight = -1.0;
      } else if (i == 0 || j == 0) {
        weight = 0.2;
      } else {
        weight = 0.05;
      }
      sum += weight * texelFetch(u_prev, wrapCell(cell + ivec2(i, j), size), 0).rg;
    }
  }
  return sum;
}

void main() {
  ivec2 size = ivec2(u_resolution);
  ivec2 cell = ivec2(gl_FragCoord.xy);

  if (u_frame == 0) {
    // Pearson の初期条件と同じ組み立て: 全体を (U, V) = (1, 0) にしてから、
    // 一部を (0.5, 0.25) に乱し、さらに ±1% の乱数で正方形の対称性を崩す
    bool seeded = hash21(cell / SEED_BLOCK) < SEED_RATIO;
    float jitter = 1.0 + 0.01 * (hash21(cell) - 0.5) * 2.0;
    fragColor = vec4((seeded ? 0.5 : 1.0) * jitter, (seeded ? 0.25 : 0.0) * jitter, 0.0, 1.0);
    return;
  }

  // ポインタから F と k を作る。x で k、y で F の帯の中の位置
  float mx = clamp(u_mouse.x / u_resolution.x, 0.0, 1.0);
  float my = clamp(u_mouse.y / u_resolution.y, 0.0, 1.0);
  float k = mix(K_MIN, K_MAX, mx);
  float f = F_BASE + (k - K_MIN) * F_SLOPE + (my - 0.5) * F_SPREAD;

  vec2 state = texelFetch(u_prev, cell, 0).rg;
  vec2 lap = laplacian(cell, size);
  float u = state.x;
  float v = state.y;

  // U + 2V → 3V の反応。U を 1 つ食って V が 1 つ増える
  float reaction = u * v * v;
  float nextU = u + DT * (DU * lap.x - reaction + f * (1.0 - u));
  float nextV = v + DT * (DV * lap.y + reaction - (f + k) * v);

  ivec2 block = cell / SEED_BLOCK;
  if (block.x == PIN_X && block.y == PIN_Y) {
    nextU = min(nextU, 0.5);
    nextV = max(nextV, 0.25);
  }

  // わざと clamp していない。DT を上げたときに発散するのが見えるようにするため
  fragColor = vec4(nextU, nextV, 0.0, 1.0);
}

コード全文

更新パスの 3 本は各節に掲載したとおりです。ここには章ローカルのハーネス、共通チャンク、表示パス 3 本、そして CPU 側のmain.tsを並べます。color.glslは第27章のファイルの複製、hash.glslは第25章のファイルの複製なので再掲しません。

src/lessons/29-feedback/feedback.ts
// 第29章 章ローカルのハーネス: ping-pong する 2 枚の FBO で「前フレームの状態」を読む
//
// 第4部のデモはここまで src/lib/fullscreen-shader.ts の startFullscreenShader だけで
// 書いてきた。あれは「フラグメントシェーダー 1 本を、毎フレーム画面へ」という形に
// 特化していて、この章がやりたいことは 3 か所でその前提から外れる。
//   (a) 描画先が画面ではなく FBO
//   (b) パスが 1 本ではなく 2 本(状態を更新するパスと、状態を絵にするパス)
//   (c) 「前フレームの状態」というテクスチャの uniform が要る
// これはオプションで足せる差ではなく前提そのものの違いなので、共通ハーネスに手を
// 入れるのではなく、章ローカルの別のハーネスにした(判断の筋は本文 3 節)。
//
// 再利用しているもの:
//   src/lib/shader.ts      … compileShader / linkProgram(第3章 → 第6章で共通化)
//   src/lib/framebuffer.ts … createRenderTarget / bindRenderTarget /
//                            resizeRenderTarget / deleteRenderTarget(第20章)
//   src/lib/fullscreen.vert … gl_VertexID で作る全画面三角形(第6章)
// リサイズ・時間・ポインタの扱いは fullscreen-shader.ts をそのまま踏襲している。

import {
  bindRenderTarget,
  createRenderTarget,
  deleteRenderTarget,
  type RenderTarget,
  resizeRenderTarget,
} from '../../lib/framebuffer';
import vertexSource from '../../lib/fullscreen.vert?raw';
import { compileShader, linkProgram } from '../../lib/shader';

export interface FeedbackOptions {
  /**
   * 状態テクスチャの 1 セルが、描画バッファの何ピクセルにあたるか(既定 1)。
   * 2 以上にすると状態の解像度が画面より粗くなる。ライフゲームのようにセルを
   * 目で数えたい場合と、更新コストを下げたい場合に使う
   */
  cellSize?: number;
  /**
   * 1 フレームあたりの更新パスの回数(既定 1)。
   * 1 より大きくすると 1 フレームで何ステップも進む(リアクション・ディフュージョン)。
   * 1 未満なら数フレームに 1 回になる(0.2 なら 5 フレームに 1 回 = 毎秒 12 世代)
   */
  stepsPerFrame?: number;
  /** devicePixelRatio の上限(既定 2)。負荷を抑えたいときは 1 に下げる */
  maxDpr?: number;
}

export interface FeedbackHandle {
  /** 描画ループを止め、リスナーを外し、FBO とプログラムを解放する */
  stop: () => void;
  /** 実際に使われた状態テクスチャの形式(読み出し行に出すため) */
  stateFormat: string;
}

/** 状態テクスチャの形式を決める。float に「描く」には拡張が要る(第20章 8 節) */
function chooseStateFormat(gl: WebGL2RenderingContext): {
  internalFormat: GLenum;
  label: string;
} {
  // RGBA16F を第一希望にする。0〜1 の外の値を持てて(本文 4 節)、
  // OpenGL ES 3.0 の Table 3.13 で texture-filterable が付いている形式でもある
  if (gl.getExtension('EXT_color_buffer_float')) {
    return { internalFormat: gl.RGBA16F, label: 'RGBA16F (EXT_color_buffer_float)' };
  }
  // 32 ビットに描けない端末のための拡張。RGBA16F への描画は実装に必須(第20章 8 節)
  if (gl.getExtension('EXT_color_buffer_half_float')) {
    return { internalFormat: gl.RGBA16F, label: 'RGBA16F (EXT_color_buffer_half_float)' };
  }
  // どちらも無ければ 8 ビットへ落とす。動くが、トレイルの減衰は途中で止まり、
  // リアクション・ディフュージョンは「書いた式とは別の系」の模様になる(本文 4 節)
  return { internalFormat: gl.RGBA8, label: 'RGBA8 (float バッファの拡張なし)' };
}

export function startFeedback(
  canvas: HTMLCanvasElement,
  simSource: string,
  showSource: string,
  options: FeedbackOptions = {},
): FeedbackHandle {
  const cellSize = Math.max(1, Math.floor(options.cellSize ?? 1));
  const stepsPerFrame = options.stepsPerFrame ?? 1;
  const maxDpr = options.maxDpr ?? 2;

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

  // 2 本のプログラム。頂点シェーダーは同じソースだが、シェーダーオブジェクトは
  // 別々にコンパイルする — linkProgram はリンク後に deleteShader するので、
  // 1 つのオブジェクトを 2 つのプログラムで使い回すことはできない
  const simProgram = linkProgram(
    gl,
    compileShader(gl, gl.VERTEX_SHADER, vertexSource),
    compileShader(gl, gl.FRAGMENT_SHADER, simSource),
  );
  const showProgram = linkProgram(
    gl,
    compileShader(gl, gl.VERTEX_SHADER, vertexSource),
    compileShader(gl, gl.FRAGMENT_SHADER, showSource),
  );

  // 更新パスの uniform。u_resolution は「状態テクスチャの解像度」
  const simPrev = gl.getUniformLocation(simProgram, 'u_prev');
  const simResolution = gl.getUniformLocation(simProgram, 'u_resolution');
  const simMouse = gl.getUniformLocation(simProgram, 'u_mouse');
  const simTime = gl.getUniformLocation(simProgram, 'u_time');
  const simFrame = gl.getUniformLocation(simProgram, 'u_frame');

  // 表示パスの uniform。u_resolution は「画面の解像度」— 更新パスとは別物
  const showState = gl.getUniformLocation(showProgram, 'u_state');
  const showResolution = gl.getUniformLocation(showProgram, 'u_resolution');
  // 表示パスも u_time を受け取れるようにしてある。この章の 3 本は宣言していないので
  // ロケーションは null で、null 相手の uniform1f は仕様どおり無視される(第4章 2 節)
  const showTime = gl.getUniformLocation(showProgram, 'u_time');

  // サンプラはどちらのパスもテクスチャユニット 0 を見る(第16章)。
  // 一度設定すればプログラムの状態として残る
  gl.useProgram(simProgram);
  gl.uniform1i(simPrev, 0);
  gl.useProgram(showProgram);
  gl.uniform1i(showState, 0);

  const stateFormat = chooseStateFormat(gl);

  /** 状態を 1 枚作る。深度は要らない。フィルタは必ず NEAREST(本文 4 節) */
  function createState(width: number, height: number): RenderTarget {
    return createRenderTarget(gl, width, height, {
      depth: false,
      filter: gl.NEAREST,
      internalFormat: stateFormat.internalFormat,
    });
  }

  function stateWidth(): number {
    return Math.max(1, Math.ceil(gl.drawingBufferWidth / cellSize));
  }
  function stateHeight(): number {
    return Math.max(1, Math.ceil(gl.drawingBufferHeight / cellSize));
  }

  // 毎フレーム先頭でサイズを確認し、変わっていたときだけ描画バッファを作り直す
  // (fullscreen-shader.ts と同じ。表示サイズ 0 でも 1px は確保する)
  function resizeCanvas(): void {
    const dpr = Math.min(window.devicePixelRatio, maxDpr);
    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;
    }
  }

  /**
   * 状態を 2 枚作る。createRenderTarget は completeness が通らなければ throw する
   * (第20章 4 節)ので、これは実際に通る道 — main.ts はその例外を捕まえて読み出し行に
   * 出している。途中で抜けると、ここまでに作ったプログラム 2 本と 1 枚目の状態が
   * 漏れる。stop() で後始末をする章がここに穴を空けているのは筋が通らないので、
   * 自分で解放してから投げ直す
   */
  function createStatePair(): [RenderTarget, RenderTarget] {
    let first: RenderTarget | null = null;
    try {
      first = createState(stateWidth(), stateHeight());
      return [first, createState(stateWidth(), stateHeight())];
    } catch (error) {
      if (first) {
        deleteRenderTarget(gl, first);
      }
      gl.deleteProgram(simProgram);
      gl.deleteProgram(showProgram);
      throw error;
    }
  }

  // canvas のサイズを決めてから状態を作る。順番を逆にすると
  // drawingBufferWidth が既定の 300×150 のまま読まれる
  resizeCanvas();
  // read = 前の状態(サンプルする側) / write = 新しい状態(描き込む側)。
  // 毎ステップこの 2 つが役を交代する。これが ping-pong
  const [initialRead, initialWrite] = createStatePair();
  let read = initialRead;
  let write = initialWrite;

  // 更新パスに渡す通し番号。0 のあいだは前の状態が無いので、シェーダー側は
  // u_frame == 0 を初期化の分岐に使う(本文 2 節)。
  // これは増え続けるので、u_frame を宣言するシェーダーは precision highp int; を
  // 書くこと — mediump の int の保証は 16 ビットしかない(本文 2 節・第25章 3 節)
  let frame = 0;
  // 1 未満の stepsPerFrame を扱うための持ち越し。1 で始めるのは、
  // 最初のフレームで必ず 1 回初期化を走らせるため
  let stepBudget = 1;

  // u_mouse は fullscreen-shader.ts と同じ「描画バッファのピクセル・左下原点」で
  // 受け取り、更新パスへ渡すときだけ状態テクスチャの座標系へ直す
  let pointerActive = false;
  let mouseX = 0;
  let mouseY = 0;

  function onPointerMove(event: PointerEvent): void {
    const rect = canvas.getBoundingClientRect();
    const x = event.clientX - rect.left - canvas.clientLeft;
    const y = event.clientY - rect.top - canvas.clientTop;
    const scaleX = canvas.width / canvas.clientWidth;
    const scaleY = canvas.height / canvas.clientHeight;
    mouseX = x * scaleX;
    mouseY = (canvas.clientHeight - y) * scaleY;
    pointerActive = true;
  }
  canvas.addEventListener('pointermove', onPointerMove);

  function resizeIfNeeded(): void {
    resizeCanvas();
    const width = stateWidth();
    const height = stateHeight();
    if (read.width === width && read.height === height) {
      return;
    }
    // resizeRenderTarget は「同じ設定で作り直す」だけなので(第20章 7 節)、
    // 中身は消える。だからリサイズしたら初期化からやり直す。黙って状態が
    // 消えるように見えないよう、この挙動は本文にも書いてある(本文 3 節)
    read = resizeRenderTarget(gl, read, width, height);
    write = resizeRenderTarget(gl, write, width, height);
    frame = 0;
    stepBudget = 1;
  }

  let rafId = 0;

  function render(timestamp: DOMHighResTimeStamp): void {
    resizeIfNeeded();
    const time = timestamp * 0.001;

    // --- 更新パス: 前の状態を読み、新しい状態を書き、役を入れ替える -----------
    gl.useProgram(simProgram);
    stepBudget += stepsPerFrame;
    while (stepBudget >= 1) {
      stepBudget -= 1;
      // 描画先を write に切り替える(viewport も一緒に切り替わる)
      bindRenderTarget(gl, write);
      gl.uniform1f(simTime, time);
      gl.uniform2f(simResolution, write.width, write.height);
      const stateScaleX = write.width / gl.drawingBufferWidth;
      const stateScaleY = write.height / gl.drawingBufferHeight;
      if (pointerActive) {
        gl.uniform2f(simMouse, mouseX * stateScaleX, mouseY * stateScaleY);
      } else {
        gl.uniform2f(simMouse, write.width / 2, write.height / 2);
      }
      gl.uniform1i(simFrame, frame);
      // サンプルするのは read。描画先は write。この 2 つが同じテクスチャに
      // なると WebGL は INVALID_OPERATION を返す(本文 1 節)。
      // バインドは必ず drawArrays の直前にやり直す — 役の交代でずれるため
      gl.activeTexture(gl.TEXTURE0);
      gl.bindTexture(gl.TEXTURE_2D, read.texture);
      gl.drawArrays(gl.TRIANGLES, 0, 3);

      const swapped = read;
      read = write;
      write = swapped;
      frame++;
    }

    // --- 表示パス: いちばん新しい状態(= read)を画面へ ------------------------
    bindRenderTarget(gl, null);
    gl.useProgram(showProgram);
    gl.uniform1f(showTime, time);
    gl.uniform2f(showResolution, gl.drawingBufferWidth, gl.drawingBufferHeight);
    gl.activeTexture(gl.TEXTURE0);
    gl.bindTexture(gl.TEXTURE_2D, read.texture);
    gl.drawArrays(gl.TRIANGLES, 0, 3);

    rafId = requestAnimationFrame(render);
  }
  rafId = requestAnimationFrame(render);

  return {
    stop(): void {
      cancelAnimationFrame(rafId);
      canvas.removeEventListener('pointermove', onPointerMove);
      // 第6章のハーネスは stop() で GPU リソースを解放していない(第35章の宿題)。
      // この章のハーネスはデモを切り替えるたびに FBO 2 枚 = テクスチャ 2 枚を
      // 作るので、放置すると切り替えた回数だけ状態テクスチャが残る。ここで消す
      deleteRenderTarget(gl, read);
      deleteRenderTarget(gl, write);
      gl.deleteProgram(simProgram);
      gl.deleteProgram(showProgram);
    },
    stateFormat: stateFormat.label,
  };
}
src/lessons/29-feedback/grid.glsl
// 第29章 状態テクスチャを「2 次元配列」として扱うための 2 行。
// 更新パスと表示パスで座標系が違う(状態の解像度 / 画面の解像度)ので、
// その行き来をここに閉じ込めている。混同すると必ずバグる場所(本文 4 節)。

/**
 * 格子の外へ出たセル番地を、反対側へ折り返す — 上下左右がつながったトーラスになる。
 *
 * texelFetch はテクスチャのラップモード(TEXTURE_WRAP_S / _T)を見ないので(第16章 7 節)、
 * 端の扱いはこちらで書くしかない。逆に言えば、端を「壁」にしたいなら
 * clamp(cell, ivec2(0), size - 1) に差し替えるだけでよい。
 *
 * size を足してから % を取っているのは、GLSL ES 3.00 の % が
 * 「どちらかの被演算子が負なら結果は未定義」(§5.9)だから。近傍は上下左右斜めの
 * 1 セルだけなので cell >= -1 で、size >= 1 なら cell + size は必ず 0 以上になる。
 */
ivec2 wrapCell(ivec2 cell, ivec2 size) {
  return (cell + size) % size;
}

/**
 * 画面のピクセル座標 → 状態テクスチャのセル番地。表示パスが使う。
 * fragCoord はピクセルの中心なので 0.5 〜 size - 0.5 の範囲にあり(第7章)、
 * 比を取って切り捨てれば 0 〜 stateSize - 1 に収まる。min はその保険 —
 * 範囲外の texelFetch は ES 3.0 では結果が未定義で、WebGL 2.0 は安全性のために
 * 「ゼロを返す」と定めている(WebGL 2.0 仕様 "Texel Fetches")。どちらにしても
 * 意図した値ではないので、はみ出させない。
 */
ivec2 screenToCell(vec2 fragCoord, vec2 screenSize, ivec2 stateSize) {
  return min(ivec2(fragCoord * vec2(stateSize) / screenSize), stateSize - 1);
}
src/lessons/29-feedback/01-trail.show.frag
#version 300 es

// 第29章 デモ1 表示パス: 状態テクスチャを画面へ出すだけ。
// 状態に入っているのは「光の量」で、1.0 を超えることもある。
// 背景色をリニア空間で足してから、最後の 1 行で sRGB へエンコードする。

precision highp float;

// float テクスチャを読むサンプラには highp を明示する(第21章 6 節)。既定は lowp
uniform highp sampler2D u_state;
// こちらの u_resolution は「画面の」解像度。更新パスとは別物
uniform vec2 u_resolution;

out vec4 fragColor;

#include "grid.glsl"
#include "color.glsl"

// ---- 書き換えて試すための定数 ----------------------------------------
// 背景の基調色(第4部の共通の値)。sRGB で書いてリニアへ直して使う
const vec3 BACKGROUND = vec3(0.06, 0.07, 0.09);
// ----------------------------------------------------------------------

void main() {
  ivec2 stateSize = textureSize(u_state, 0);
  ivec2 cell = screenToCell(gl_FragCoord.xy, u_resolution, stateSize);

  vec3 glow = texelFetch(u_state, cell, 0).rgb;

  // 足し算はリニア空間で(第27章 4 節)。背景も光もリニアの量として扱う
  vec3 color = srgbToLinear(BACKGROUND) + glow;

  fragColor = vec4(linearToSrgb(color), 1.0);
}
src/lessons/29-feedback/02-life.show.frag
#version 300 es

// 第29章 デモ2 表示パス: 0 と 1 の格子を、セルが見える大きさで描く。
// 状態テクスチャは画面より粗い(1 セル = CELL_SIZE ピクセル)ので、
// 画面のピクセルからセルの番地を割り出してから読む。

precision highp float;

// float テクスチャを読むサンプラには highp を明示する(第21章 6 節)。既定は lowp
uniform highp sampler2D u_state;
uniform vec2 u_resolution; // 画面の解像度

out vec4 fragColor;

#include "grid.glsl"
#include "color.glsl"

// ---- 書き換えて試すための定数 ----------------------------------------
// 死んでいるセルの色 / たったいま死んだセルの余韻 / 生きているセルの色(いずれも sRGB)
const vec3 DEAD = vec3(0.06, 0.07, 0.09);
const vec3 GHOST = vec3(0.14, 0.19, 0.28);
const vec3 LIVE = vec3(0.86, 0.92, 1.00);
// セルの境目に沈める線の色と太さ(画面のピクセル)
const vec3 GRID_LINE = vec3(0.02, 0.02, 0.03);
const float GRID_WIDTH = 1.0;
// ----------------------------------------------------------------------

void main() {
  ivec2 stateSize = textureSize(u_state, 0);
  // grid.glsl の screenToCell とまったく同じ計算だが、下の境目の線に小数部
  // fract(cellF) が要るので、切り捨てる前の値を自分で持っている
  vec2 cellF = gl_FragCoord.xy * vec2(stateSize) / u_resolution;
  ivec2 cell = min(ivec2(cellF), stateSize - 1);

  // r = いまの世代 / g = 1 つ前の世代
  vec2 state = texelFetch(u_state, cell, 0).rg;

  // 混ぜるのはリニア空間で。生死そのものは 0 か 1 しかないので、
  // 生きたセルの色は LIVE、死んだセルの色は DEAD にぴったり一致する
  vec3 color = mix(srgbToLinear(DEAD), srgbToLinear(GHOST), state.g);
  color = mix(color, srgbToLinear(LIVE), state.r);

  // セルの境目。第4部の仕切り線と同じ式を、格子の目に当てている
  vec2 toEdge = min(fract(cellF), 1.0 - fract(cellF)) * u_resolution / vec2(stateSize);
  float edge = smoothstep(0.0, GRID_WIDTH, min(toEdge.x, toEdge.y));
  color = mix(srgbToLinear(GRID_LINE), color, edge);

  fragColor = vec4(linearToSrgb(color), 1.0);
}
src/lessons/29-feedback/03-reaction.show.frag
#version 300 es

// 第29章 デモ3 表示パス: V の濃度を色に写す。
// 状態に入っている V の最大は (F, k) によって 0.24〜0.47 まで振れる(CPU で 128×128 を
// 2000 ステップ、デモが割り当てる帯を 9×5 で掃いて測った値)。0.43 あたりを 1.0 に
// 合わせる目安で伸ばしてから 3 色の階調へ流し、それを超える帯は下の clamp で白に潰す。
// 色そのものの設計(余弦パレットなど)は第27章 7 節の担当。

precision highp float;

// float テクスチャを読むサンプラには highp を明示する(第21章 6 節)。既定は lowp
uniform highp sampler2D u_state;
uniform vec2 u_resolution; // 画面の解像度

out vec4 fragColor;

#include "grid.glsl"
#include "color.glsl"

// ---- 書き換えて試すための定数 ----------------------------------------
// V を 0〜1 へ伸ばす倍率。0.43 × 2.4 ≒ 1.03 が目安(本文 7 節)
const float V_SCALE = 2.4;
// 3 色の階調(いずれも sRGB で書き、リニアへ直して混ぜる)
const vec3 LOW = vec3(0.06, 0.07, 0.09);
const vec3 MID = vec3(0.17, 0.35, 0.55);
const vec3 HIGH = vec3(0.95, 0.97, 0.92);
// ----------------------------------------------------------------------

void main() {
  ivec2 stateSize = textureSize(u_state, 0);
  ivec2 cell = screenToCell(gl_FragCoord.xy, u_resolution, stateSize);

  float v = clamp(texelFetch(u_state, cell, 0).g * V_SCALE, 0.0, 1.0);

  vec3 color = mix(srgbToLinear(LOW), srgbToLinear(MID), smoothstep(0.0, 0.55, v));
  color = mix(color, srgbToLinear(HIGH), smoothstep(0.52, 1.0, v));

  fragColor = vec4(linearToSrgb(color), 1.0);
}
src/lessons/29-feedback/main.ts
// 第29章: フィードバック
// 「1 canvas + 切替ボタン」構成は第10章以来のものと同じ。違うのは 2 つだけ。
//   - ハーネスが startFullscreenShader ではなく、この章の startFeedback(理由は 3 節)
//   - デモ 1 本につきシェーダーが 2 本(更新パス .sim.frag / 表示パス .show.frag)
// #include の解決は第23章からの文字列置換。置換文字列を関数で渡しているのは、
// String.replace が `$&` などを特別扱いするため。

import trailShowSource from './01-trail.show.frag?raw';
import trailSimSource from './01-trail.sim.frag?raw';
import lifeShowSource from './02-life.show.frag?raw';
import lifeSimSource from './02-life.sim.frag?raw';
import reactionShowSource from './03-reaction.show.frag?raw';
import reactionSimSource from './03-reaction.sim.frag?raw';
import colorChunkSource from './color.glsl?raw';
import { type FeedbackHandle, type FeedbackOptions, startFeedback } from './feedback';
import gridChunkSource from './grid.glsl?raw';
import hashChunkSource from './hash.glsl?raw';

function resolveIncludes(source: string): string {
  return source
    .replace('#include "hash.glsl"', () => hashChunkSource.trim())
    .replace('#include "grid.glsl"', () => gridChunkSource.trim())
    .replace('#include "color.glsl"', () => colorChunkSource.trim());
}

interface Demo {
  id: string;
  label: string;
  sim: string;
  show: string;
  options: FeedbackOptions;
}

const demos: Demo[] = [
  {
    id: 'trail',
    label: 'トレイル',
    sim: resolveIncludes(trailSimSource),
    show: resolveIncludes(trailShowSource),
    // 状態は画面と同じ解像度。1 フレーム 1 ステップ = 毎秒 60 回減衰する
    options: { cellSize: 1, stepsPerFrame: 1 },
  },
  {
    id: 'life',
    label: 'ライフゲーム',
    sim: resolveIncludes(lifeSimSource),
    show: resolveIncludes(lifeShowSource),
    // 1 セル = 10 ピクセルの粗い格子。6 フレームに 1 世代 = 毎秒 10 世代。
    // 毎フレーム進めると速すぎて、どのセルがどう動いたのか目で追えない
    options: { cellSize: 10, stepsPerFrame: 1 / 6 },
  },
  {
    id: 'reaction',
    label: 'リアクション・ディフュージョン',
    sim: resolveIncludes(reactionSimSource),
    show: resolveIncludes(reactionShowSource),
    // 模様ができるまでに 1000 ステップ以上かかるので、1 フレームで 8 回進める。
    // 1 セル = 3 ピクセルにしているのは、更新の回数が 8 倍あるぶんの節約
    options: { cellSize: 3, stepsPerFrame: 8 },
  },
];

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

let handle: FeedbackHandle | null = null;

// 関数宣言ではなくアロー関数にしているのは、上の null チェックによる型の絞り込みを
// この中でも効かせるため(関数宣言は巻き上げられるので、TypeScript は絞り込みを持ち越さない)
const start = (demo: Demo): void => {
  try {
    handle = startFeedback(canvas, demo.sim, demo.show, demo.options);
    const cell = demo.options.cellSize ?? 1;
    readout.textContent = `状態テクスチャ: ${handle.stateFormat} / 1 セル = ${cell} ピクセル`;
  } catch (error) {
    // createRenderTarget は completeness が通らなければステータス名付きで
    // throw する(第20章 4 節)。原因が分かるように、そのまま読者へ見せる
    handle = null;
    readout.textContent = error instanceof Error ? error.message : String(error);
  }
};

start(demos[0]);

for (const [index, demo] of demos.entries()) {
  const button = document.createElement('button');
  button.type = 'button';
  button.textContent = demo.label;
  button.setAttribute('aria-pressed', index === 0 ? 'true' : 'false');
  button.addEventListener('click', () => {
    // このハーネスの stop() は FBO とプログラムまで解放する(第6章のハーネスは
    // していない。一般論は第35章)。状態テクスチャは画面いっぱいの大きさなので、
    // 切り替えるたびに残していくと効いてくる
    handle?.stop();
    start(demo);
    for (const b of controls.querySelectorAll('button')) {
      b.setAttribute('aria-pressed', 'false');
    }
    button.setAttribute('aria-pressed', 'true');
  });
  controls.append(button);
}

three.js との対応

ping-pong は three.js でもよく使う手法なので、この章は対応が付けやすい部分です。ただし 「2 枚を入れ替える」段取りそのものは three.js も用意していないので、自分で書きます。

three.jsこの章
WebGLRenderTargetを 2 つ作り、renderer.setRenderTargetで切り替えて描くcreateRenderTarget(第20章)を 2 つとbindRenderTarget。入れ替えは変数の交換
type: THREE.HalfFloatType/FloatTypeinternalFormatにgl.RGBA16F/gl.RGBA32F。描くには拡張が要る(第20章 8 節・この章 4 節)
minFilter/magFilterをTHREE.NearestFilterfilter: gl.NEAREST。ただしこの章は更新も表示もtexelFetchで読むので、フィルタが実際に補間へ効く場面は無い。それでもNEARESTにしておくのは、テクスチャを完備に保つため(4 節)
uniforms.tPrev.value = readTarget.textureactiveTexture+bindTextureをdrawArraysの直前に。uniform1iは 0 番固定で 1 回だけ(第16章)
GPUComputationRenderer(examples の補助クラス)src/lessons/29-feedback/feedback.ts。やっていることはほぼ同じで、 変数の本数を 1 本に固定したぶん短い
renderer.autoClear = falseで前フレームの上に描き足す使わない。既定のフレームバッファは 8 ビットなので、5 節の理由で FBO に状態を持つ

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

この章のコードはsrc/lessons/29-feedback/にあります。リサイズすると状態が初期化されるので、初期状態から見たくなったらウィンドウの幅を少し変えてください。

まとめ

これで「1 枚のテクスチャに状態を持たせて、全画面で一斉に更新する」形の作り方が身につきました。 状態の置き場が格子である限り、セルの数は画面の広さで決まり、どのセルも同じ規則で 更新されます。次章第30章「ブレンディングとパーティクル」は、そこから発想を ひっくり返します — 格子を捨てて、たくさんの点をばらばらの場所に描いて重ねる。 重ねるための道具がblendFuncと加算合成、点そのものを大きく描く道具がgl_PointSize(第5章の伏線)です。透明と深度テストの兼ね合い、そして描画順の話も そこで扱います。パイプライン図にブレンド段が加わるのも次章です。