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

色とパレット

#808080は「白の半分の明るさ」ではありません。光の量として測ると21.6%です(コード 128 は 0〜1 の目盛りでは 0.502 です。ちょうど 0.5 なら 21.4% — この章では、この 2 つを混ぜないことがそのまま主題になります)。逆に、光の量がちょうど半分の灰色は#BCBCBC(= 188) です。この 1 行が、この章のすべての出発点になります。

第7章で「0〜1 の RGB をそのまま出力すれば、その色になる」という素朴な前提を置き、 そのまま第26章まで来ました。第17〜18章では光を足し、第22章ではpow(c, vec3(2.2))という近似でリニア空間に出入りし、第23章ではトーンマッピングを外して定数の環境光に戻しました。 どれも「正確な話は第27章で」と書いてあります。その請求書がこの章です。扱うのは 3 つ — どの空間で数を持つか(1〜4 節)、0〜1 に収まらない値をどうするか(5〜6 節)、色を設計して出力に落とす(7〜8 節)。

この章のデモ 4 本。ボタンで切り替えられます。「取り違え 正/誤」は 2×2 で上段が正しく・下段が間違い(上段の 2 区画それぞれの左上に印があります)、「露出とトーンマップ」は同じシーンを 3 つの写し方で並べた縦 3 帯(ポインタ左右で露出 −3〜+3 ストップ)、「余弦パレット」は Inigo Quilez の式によるパレット 3 本と、それを距離に当てた区画(ポインタ左右で位相・上下で周期)、「ディザ あり/なし」は暗部の緩いグラデーションで、上段だけにディザを掛けたもの(ポインタ左右でグラデーションの上端)です。

この章で学ぶこと:

1. 0.5 は中間の明るさではない — sRGB エンコードと知覚

画面に出す色は 0〜1 の 3 つ組で書きます。この数が光の量に比例していると思っていたのが、これまでの素朴な前提でした。実際には比例していません。0〜1 の値はsRGB エンコード値という目盛りで、そこから光の量(リニア値)へ移すには決まった曲線を通します。

その曲線は OpenGL ES 3.0.6 の §3.8.16 式 (3.26) に書かれています。エンコード値csからリニア値clへは、cs ≤ 0.04045ならcs / 12.92、そうでなければ((cs + 0.055) / 1.055)^2.4。この式に 0.5 を入れると0.214041が出ます。逆向きの式(§4.1.8 式 (4.1))にリニアの 0.5 を入れると0.735357で、8 ビットに丸めると 188 / 255 です。

sRGB エンコード値(画面に出す 0〜1 の目盛り)リニア値(光の量)0.5 → 0.2140.735 ← 0.500.5100.51直線なら、こう実際の伝達関数
sRGB の伝達関数。曲線が破線(y = x)より下にあるので、同じ数値でも光の量は少ない。曲線が原点付近でほとんど寝ているのがこの設計の要点です(傾きは cs = 0 で1/12.92= 0.077、cs = 1で 2.275)。同じ 1 段のコード差に対応する光の量が、暗部では小さい— つまり暗い側に目盛りを細かく配っている、ということです。

なぜこんな形をしているのか。答えは8 ビットしかない帯域をどこに配るかという設計です。8 ビットのコードを 1 つ進めたとき、リニア値がどれだけ増えるかを測ると、

で、暗部の刻みは明部の 29.3 分の 1です。もし 0〜1 のリニア値をそのまま 8 ビットで持ったなら刻みは一定の 1/255 = 3.9216 × 10⁻³ になり、これは sRGB の最小の刻みの 12.92 倍粗い(比がちょうど 12.92 になるのは、原点付近の枝がcs / 12.92という直線だからです)。同じ細かさをリニアのまま得ようとすると、刻み 3.0353 × 10⁻⁴ で 0〜1 を 覆うのに3295 段(= 3296 個のコード)— つまり 12 ビットが必要になります。8 ビットに収めたまま暗部の階調を保つための変換、というのが sRGB エンコードの実務上の意味です。

配分を別の角度から見るとこうです。256 個のコードのうち、リニア値が 0.5 以上のものは 68 個しかありません。光の量で言えば上半分に 68 コード、下半分に 188 コード。「暗いほうに手厚い」という設計が、 そのまま数に出ています。

知覚の話は 1 段落で済ませます。人間の明るさの弁別は「差」よりも「比」に近いので、同じ 1 段の変化でも、暗いところでは比が大きく、目につきやすくなります。実際に測ると、コード 1 → 2 はリニア値が 2.000 倍(1 ストップぶん!)、コード 254 → 255 は1.009 倍です。sRGB エンコードはこの偏りを完全に均すものではありません— リニアのまま持つよりは暗部に寄せている、という程度のものです。だから 8 節で扱うバンディングは、いちばん暗いところで最後まで残ります。

2. リニアと sRGB の往復 — 正確な伝達関数と pow(2.2) 近似

式をそのままコードにします。この 2 つが、このサイトの色の「正本」です。第28章・第29章のcolor.glslはこの 2 関数の複製で、内容はバイト単位で同じにしてあります(第25 → 26 章のnoise.glslと同じ流儀です)。

src/lessons/27-color-and-palette/color.glsl(抜粋)
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));
}
src/lessons/27-color-and-palette/color.glsl(抜粋)
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));
}

仕様の式は 2 本の枝に分かれていますが、コードには ifが出てきません。stepとmixで書いてあるのは、境目で 2 つの枝の値がほとんど同じだからです。境界で 両方の式を評価して差を測ると、

しかありません。stepの等号がどちら側に付くかを気にしなくてよい、ということです。分岐がないので、全成分をvec3のまま一度に処理できます。

linearToSrgbの頭にclampがあるのは、仕様の式が 4 本の枝を持っていて、cl ≤ 0を 0、cl ≥ 1を 1 に落としているからです。この clamp が「1 を超えた情報はここで消える」場所で、5 節でもう一度出てきます。

さて、第22章はこの 2 つを pow(c, vec3(2.2)) とpow(c, vec3(1.0/2.2))で済ませていました。どれくらい違うのかを測ります。

向き近似最大絶対差それが出る場所
sRGB → リニアpow(c, 2.2)8.528 × 10⁻³c ≈ 0.7496
リニア → sRGBpow(c, 1/2.2)3.352 × 10⁻²cl ≈ 0.00216(暗部)

出力側の差 3.352 × 10⁻² は、8 ビットに落とすと 最大 9 段 / 255(cl ≈ 0.00131のとき)になります。ここが要点です —pow 近似の誤差は暗部で最大になる。1 節で見たとおり暗部は 1 段の比が大きいところなので、いちばん見えるところで いちばんずれる、という組み合わせです。

正確な式のほうは、8 ビットの 256 値すべてについてlinearToSrgb(srgbToLinear(c))が元の値にぴたりと戻ります(256 / 256)。往復で階調が失われません。

3. どこで変換するか — 読み込み時と出力時

ここまでで道具は揃いました。あとはどこで使うかだけで、答えは 2 か所です。

  1. sRGB として作られたデータを読んだ直後に srgbToLinear
  2. 画面へ出す直前に linearToSrgb

その間はすべてリニアです。「間」に含まれるのは、ライティング、加算、mix、ぼかし、積分、トーンマッピング — つまり数として意味のある操作の全部です。

ここはすべてリニア値sRGB の画像(第16章・glTF)srgbToLinearパレット・定数(リニアで設計する)ノイズ・SDF(単位のない 0〜1)加算・mix積分・ぼかし露出(× 2ⁿ)トーンマッピング0〜∞ → 0〜1linearToSrgb画面(8 ビット)
色のパイプライン。変換は入口と出口の 2 か所だけで、間はすべてリニアです。 入口で変換が要るのは「sRGB として作られたもの」だけ。ノイズや SDF の値のように単位のない 0〜1は、リニア値として扱うと決めれば変換は要りません(第25〜26章の値がこれです)。

入口の側で気をつけるのは、そのデータが sRGB として作られているかどうかです。判断の目安を並べます。

データsRGB か入口での扱い
PNG / JPEG の色画像(第16章)ほぼ必ず sRGBsrgbToLinear、またはSRGB8_ALPHA8で GPU に変換させる
法線マップ・粗さマップ・深度色ではない変換しない(数値として読む)
glTF の baseColorTexture仕様が「sRGB 伝達関数で符号化されていなければならない」と定めているsrgbToLinear(仕様も「計算に使う前にリニアへ復号せよ」と書いている)
glTF の metallicRoughnessTexture / normalTexture仕様が「リニア伝達関数で符号化」と定めている変換しない
glTF の baseColorFactor / COLOR_0「リニアな乗数」と定義されている変換しない(読んだ値にそのまま掛ける)
CSS の色・カラーピッカーで選んだ値sRGBsrgbToLinear
ノイズ・SDF・fbm の値(第24〜26章)どちらでもないリニアとして扱うと決める(7 節)

出口の側は 1 行です。この章のデモはすべて linearToSrgbで終わっています。デモ 2 の末尾を見てください。

src/lessons/27-color-and-palette/02-exposure-tonemap.frag(抜粋)
  // 0〜1 に畳んでから、最後に sRGB へ。この順序が肝(5 節)
  vec3 color = linearToSrgb(mapped);

GPU にやらせる — SRGB8_ALPHA8

テクスチャの内部形式を SRGB8_ALPHA8(または SRGB8)にしておくと、texture()が返す時点ですでにリニア値になっています。使う式は ES 3.0.6 §3.8.16 式 (3.26) — つまり2 節で自分で書いた srgbToLinear とまったく同じ式です。 シェーダー側で掛けるか GPU に任せるかは、式の選択の問題ではなく、どこで行うかの問題です。 アルファは変換されません(§3.8.16 が「Any alpha component is left unchanged」と明記しています)。

ただし「フィルタリングはリニア値で行われる」と言い切ることはできません。§3.8.16 の書き方はこうです —理想的には各サンプルをフィルタリングの前に変換するべきだが、実装はフィルタリングの後に 変換することも許される(そのほうが劣ると仕様自身が但し書きしています)。つまりSRGB8_ALPHA8+ LINEAR の組み合わせで、混色がリニアで行われる保証はありません。 シェーダーでsrgbToLinearを掛ける場合は、テクセルの補間そのものは sRGBエンコード値のまま行われるので、こちらは「必ず後」で確定しています。

この「前か後か」は仕様書の側でも意識されています。glTF 2.0 仕様の base color texture の項は「正しいフィルタリングのためには、線形補間を行う前に伝達関数を復号するべきである(SHOULD)」と書いています。正しい順序は決まっているが、どちらも規格の言葉としては「べき」止まり、というのが現状です。1 テクセルが数ピクセルに拡大されるような使い方では差が見えるので、 気になる場面ではtexelFetchで 4 テクセル読んで自分で補間する、という逃げ道もあります(第16章 7 節)。

第22章の pow(2.2) は、実際どれだけ効いていたか

第22章の環境マップは public/textures/env/*.png の 8 ビット PNG で、irradiance.frag/ prefilter.frag /skybox.fragの 3 か所がpow(srgb, vec3(2.2))で読んでいます。出口はこうです。

src/lessons/22-pbr/pbr.frag(再掲・抜粋)
  // ここまでリニア。画面へ出す直前にだけガンマを掛ける(6 節。正確な話は第27章)
  fragColor = vec4(pow(color, vec3(1.0 / 2.2)), 1.0);

入力が 8 ビットなので、確かめるべき値は 256 個だけです。全部について正確な式とpow(2.2)を比べると、最大差は コード 191 の8.528 × 10⁻³(正確 0.520996 に対し近似 0.529523、相対 1.64% 明るすぎ)。これは絵として問題になる量ではありません。効くのは暗部です。

8 ビットのコード正確な式pow(2.2)近似 / 正確
13.0353 × 10⁻⁴5.0771 × 10⁻⁶0.017
41.2141 × 10⁻³1.0719 × 10⁻⁴0.088
165.1815 × 10⁻³2.2630 × 10⁻³0.437
321.4444 × 10⁻²1.0398 × 10⁻²0.720
1282.1586 × 10⁻¹2.1952 × 10⁻¹1.017

コード 1 では、近似は正確な値の 1.7% しか返しません(60 分の 1 です)。pow近似は原点で傾きが 0 なので、直線の枝を持っている正確な式に比べて最暗部を潰します。第22章の環境マップは全体が明るい空なので絵は成立しますが、「暗い環境で IBL の色が乗らない」という症状が出たら、まずここを疑う場所です。

第25〜26章のノイズについても 1 行だけ。あれらの値は光の量ではないので、「リニア値として扱う」と決めれば、変換は要りません。決めずに使うと、同じmixが場所によって違う意味になります。7 節の末尾で、パレットと合わせて明文化します。

4. 足し算はリニア空間でしか合わない

第18章で「光は足し算でよい— 2 つの光源に照らされた面の明るさは、それぞれで照らしたときの明るさの和になります。これは近似ではなく、 光の物理がそもそも線形だからです」と書き、続けて「ただし『0〜1 の RGB 値をそのまま足す』ことが色として正しいかは別の話で、そこは第27章の担当です」と送りました。 ここで回収します。光が線形なのは「光の量」の話で、sRGB エンコード値ではありません。

数値で見ます。sRGB エンコード値 0.5 の光を 2 つ重ねると、

素朴なほうは、たった 2 灯で白に飛びます。リニアで本当に 1.0 に届くのは、sRGB のコード 188 の光を 2 つ足したときです。「0〜1 の値を足したら 1 を超えた」ことと「光が飽和した」ことは、別の出来事なのです。

mixも同じです。黒と白のちょうど中間を作りたいとき、

前者は後者の 42.8% の明るさ、つまり 57.2% 暗い。これが 「グラデーションの真ん中が沈む」「2 色を混ぜると濁る」の正体です。デモ 1 がこれを並べたものです。上段が正しく、下段が間違いで、両端の色は上下でまったく同じにしてあります。違うのは真ん中だけです。

デモ 1 は 2×2 で、左列と右列は別の題材です。左列が「0〜1 の値を光の量として作ったのに、そのまま画面へ出した」誤り、右列が「2 色を sRGB エンコード値のまま混ぜた」誤りで、どちらも上段が 正しい側です。まず右列の 1 行。

src/lessons/27-color-and-palette/01-linear-vs-srgb.frag(抜粋)
    color = correct
      ? linearToSrgb(mix(MIX_A, MIX_B, t))
      : mix(linearToSrgb(MIX_A), linearToSrgb(MIX_B), t);

左列は 1 行だけです。t を「光の量」として作ったつもりの灰色階調です。

src/lessons/27-color-and-palette/01-linear-vs-srgb.frag(抜粋)
    // グレーのグラデーション。t は「光の量」= リニア値として作ったつもり
    color = correct ? linearToSrgb(vec3(t)) : vec3(t);

この左列で、正しい側と間違い側のずれが最大になるのはt ≈ 0.2444のあたりで、差は 73 / 255 段もあります。左列の下段が「中央より左がまとめて 暗い」ように見えるのは、この 73 段です。

ここまでは「1 を超えない」範囲の話でした。実際のシーンはすぐ超えます。第18章の距離減衰1/d²を思い出すと、d = 0.5で 4、d = 8で 0.015625 — 比は256 倍 = 8 ストップです。光源を 2 つ 3 つ置けば、明るいところは軽く 10 を超え、暗いところは 0.01 を切ります。第18章がわざわざ「飛びにくい緩やかな減衰のほうが 扱いやすい」と書いていたのは、これを 0〜1 に押し込む方法をまだ持っていなかったからです。 次の節でそれを用意します。

5. 露出とトーンマッピング

ここで 2 つの操作を、名前をはっきり分けて定義します。

露出は「絵のどの明るさを主役にするか」を決め、トーンマッピングは「主役から外れた明るさを どう畳むか」を決めます。順番は露出が先です。デモ 2 のコードがそのままの形です。

src/lessons/27-color-and-palette/02-exposure-tonemap.frag(抜粋)
  float stops = mix(EXPOSURE_MIN, EXPOSURE_MAX, u_mouse.x / u_resolution.x);
  float exposure = exp2(stops); // 1 ストップ = 2 倍

  // ここまでずっとリニア。露出は「リニア値への掛け算」なのでトーンマップより先
  vec3 hdr = (srgbToLinear(BACKGROUND_SRGB) + lightSum(p)) * exposure;

シーンは第18章と同じ「2 灯 + 逆二乗減衰」です。リニア値は灯の中心で 12.5、 いちばん遠い隅で 0.021 — 比で約 600 倍 = 9.2 ストップあります。1.0 を超えるのは帯の面積の 3.7% ほどで、露出 0 ストップなら形が失われるのはその部分だけです(明るさのほうは 3 帯とも全域で違います — 少し先の表がそれです)。3 帯とも同じこの値を作って、写し方だけを変えています。

src/lessons/27-color-and-palette/02-exposure-tonemap.frag(抜粋)
vec3 lightSum(vec2 p) {
  vec2 d1 = p - LIGHT1_POSITION;
  vec2 d2 = p - LIGHT2_POSITION;
  return LIGHT_INTENSITY
    * (LIGHT1_COLOR / (dot(d1, d1) + FALLOFF_EPSILON)
     + LIGHT2_COLOR / (dot(d2, d2) + FALLOFF_EPSILON));
}

比べる 3 つ

① そのまま clamp。1 を超えたぶんを切り落とします。1 を超えた領域はすべて同じ値になるので、そこにあった形は消えます。デモ 2 の左帯で、光源のまわりが白い円盤になっているのがそれです。

② Reinhard。x / (1 + x)です。0 で 0、単調増加で、どんなに大きい入力でも 1 に届きません(x = 1000でも 0.999)。切り落としが起きないのが取り柄です。

src/lessons/27-color-and-palette/color.glsl(抜粋)
vec3 toneMapReinhard(vec3 x) {
  return x / (1.0 + x);
}

出典は Erik Reinhard, Michael Stark, Peter Shirley, James Ferwerda,Photographic Tone Reproduction for Digital Images, ACM SIGGRAPH 2002(ACM Trans. Graph. 21(3), pp.267–276)の式 (3)です。ACM 版は有料なので、式番号は同著者・同題の University of Utah テクニカルレポートUUCS-02-01(2002 年 1 月 14 日)の PDF を開いて確認しました。そこには、 式 (3) の前後にこの章では扱わない中身があります。

大事なのは、論文の式 (3) は「輝度」1 チャンネルに掛けるものだということです。上のコードのように RGB の各成分へ別々に掛けるのは論文の手続きではありません。 どう違うのかを 1 文で書くと、輝度 1 つに掛けるなら 3 成分は同じ倍率で縮むので色味は変わらないのに対し、成分ごとに掛けると大きい成分ほど強く圧縮されるので、3 成分の比が動く。だから per-channel 版では、明るい色は彩度が落ちて白に寄ります。GLSL でcolor / (1.0 + color)と書かれているものを「Reinhard」と呼ぶのは慣用ですが、論文がそう書いていると思ってはいけない、という線を引いておきます。three.js (r185) のReinhardToneMappingも同じ per-channel 版で、ソースのコメントは出典としてこの UUCS-02-01 の URL を挙げています(こちらもソースを開いて確認しました)。

③ フィルミックな S 字カーブ。暗部を急に持ち上げず、明部をゆるやかに畳んで、 フィルムのような立ち上がりを作る形です。ここで名前の整理をしておきます。第22章 6 節が「もっと絵作りに寄せたカーブ(ACES、AgX など)もありますが、比較と使い分けは第27章」と 書いて送ってきたのが、この話です。

ACES は曲線 1 本ではない

ACES (Academy Color Encoding System)は、映画芸術科学アカデミー (AMPAS) 由来の色管理の体系です。中身は大きく 2 つに分かれます。

ACES 1.x の RRT の中身を ampas/aces-dev(タグ v1.3)のtransforms/ctl/rrt/RRT.ctlで読むと、順にこうなっています — グロー処理 (glow module)(彩度からsigmoid_shaperを作って全体に掛ける)、赤の色相修正(rgb_2_hueとcubic_basis_shaperで赤付近だけを動かす)、AP0_2_AP1_MATによる行列変換、RRT_SAT_MATによる彩度落とし、各成分への segmented_spline_c5_fwd(区分スプラインの トーンスケール)、最後に AP1_2_AP0_MAT。色相と彩度に依存する処理を含む多段の変換であって、1 変数の関数ではありません。

だから GLSL の関数 1 本は ACES ではありえません。流通している短い式は どれも「ACES の ODT(RRT(x)) の出力を標本して当てはめたフィット」です。この章で使うのは、そのうち出典を特定できた 1 つです。

src/lessons/27-color-and-palette/color.glsl(抜粋)
vec3 toneMapNarkowicz(vec3 x) {
  const float a = 2.51;
  const float b = 0.03;
  const float c = 2.43;
  const float d = 0.59;
  const float e = 0.14;
  return clamp((x * (a * x + b)) / (x * (c * x + d) + e), 0.0, 1.0);
}

この 5 つの係数(2.51 / 0.03 / 2.43 / 0.59 / 0.14)は、Krzysztof Narkowiczが 2016 年 1 月 6 日の記事ACES Filmic Tone Mapping Curveで公開したものです。記事を開いて確認できた、本人の記述はこうです。

実際に x = 1 を入れると 0.8038 で、記事の「約 0.8」と 合います。これは ACES の近似(フィット)であって ACES ではないので、この章ではtoneMapNarkowiczという名前にしてあります。記事のコードの関数名がACESFilmなので「ACES を実装した」と誤解されやすい式です。

フィットは 1 つではありません。three.js (r185) のACESFilmicToneMappingは、この 5 係数の式ではなく別のフィットを使っています。ソースを開くと、 (1)sRGB → XYZ → D65_2_D60 → AP1 → RRT_SATをまとめた 3×3 行列、(2) RRTAndODTFit という 2 次 / 2 次の有理関数(出典は Stephen Hill の selfshadow/ltc_code とコメントにあります)、(3)ODT_SAT → XYZ → D60_2_D65 → sRGBをまとめた出力側の 3×3 行列、という 3 段です(入力側の行列を掛け戻す「逆行列」ではありません — 入口の RRT_SAT と出口の ODT_SATは別の飽和行列なので、2 つを掛けても単位行列にはなりません。実際に掛けると対角が 0.92〜0.97 です)。さらに露出にtoneMappingExposure / 0.6という独自のスケールが掛かっていて、コメントは「明るい視聴環境に合わせるための変更で、1/0.6 は主観的な値」と断っています。第22章の three.js 対応表が「AP1 色空間への行列変換 + 有理関数のフィットで、さらに露出に / 0.6の独自スケールが掛かる」と書いていたのは、この 3 段のことです — ソースを読んで記述どおりだと確認しました。

まとめると、「ACES」というラベルの付いた GLSL の式には少なくとも 2 系統(Narkowicz の 5 係数版、Hill の行列 + 有理関数版)があり、係数も色空間の扱いも違うので同じ絵にはなりません。名前ではなく誰のどのフィットかで指すのが安全です。

x = 0.18(中間グレー)x = 7.24 で飽和clampReinhardNarkowicz の近似012468リニア入力01
3 つのトーンカーブ。clampは 1 で折れて水平になり、そこから先の情報が全部同じ値になります。Reinhard は 1 に漸近して到達しません。Narkowicz の近似は x ≈ 7.24で 1 に達して、そこから先はclampと同じです。3 本とも同じ入力に対する出力なので、縦の差がそのまま見た目の差になります。
リニア入力 xclampReinhardNarkowicz の近似
0.18(中間グレー)0.18000.15250.2669
0.500.50000.33330.6163
1.001.00000.50000.8038
2.001.00000.66670.9149
4.001.00000.80000.9734
8.001.00000.88891.0000

表の読み方を 2 つ。まず Reinhard は中間グレーを暗くします(0.18 → 0.1525)。全体を写像しているので当然で、第22章 6 節が「空が 0.5 に押し込まれる」と書いていたのと同じ現象です。だから実務では手前に露出を挟みます。 一方 Narkowicz の近似は 0.18 → 0.2669 と持ち上げます— 記事にあった 「入力の 1 が約 0.8 に写るよう露出込みで当てはめた」ぶんです。トーンカーブを変えると露出のちょうどよい値も変わるので、2 つは独立ではありません。デモ 2 でポインタを左右に振ると、3 帯の「ちょうどよい位置」が違うことが見えます。

この章では AgX の曲線は実装しません。扱う範囲を「露出とトーンマッピングを 切り分け、順序を確定させ、切り落とし / 漸近 / S 字の 3 通りを同じ絵で並べて見る」ことに絞ったからです。AgX は「Rec.2020 への行列 → log2 エンコード → シグモイド → 逆行列 → 色域の丸め」という多段構成で、原色の 変換(3×3 行列)まで含みます。それはこの章が扱っていない「原色が違う色空間を混ぜる」話に踏み込むことになるので、3 節の asideと同じ線でここも止めておきます。デモに載っているのはclamp/ Reinhard / Narkowicz の 3 つだけで、上の表とグラフもその 3 つです。

順序 — トーンマップは sRGB エンコードの前

これがいちばん多いバグです。トーンマッピング → sRGB エンコードの順で、 逆にはできません。実際に入れ替えて計算するとこうなります。

リニア x① linearToSrgb(reinhard(x))(正しい)② reinhard(linearToSrgb(x))(間違い)
0.050.2417(コード 62)0.1986(コード 51)
0.500.6125(コード 156)0.4237(コード 108)
1.000.7354(コード 188)0.5000(コード 127)
2.000.8360(コード 213)0.5000(コード 127)
100.00.9956(コード 254)0.5000(コード 127)

② の下 3 行が全部同じ値です。linearToSrgbの中のclamp(2 節)が先に働いて、1 を超えていた情報をトーンマップが見る前に 捨ててしまうからです。トーンマッピングは「0〜1 に収まらない値を畳む」ための道具なのに、収まらない値が もう残っていない。順序を逆にすると、全体が暗くなるうえに明部の階調が完全に失われます。

第23章では、64 灯が重なるのにトーンマッピングを外してガンマだけを掛けていました(章の焦点を 二重にしないためです)。だから明るいところは白に飽和しています。上の表の ① を挟めば直る、というのがその答えです。

6. HDR 画像の扱い

リニアで計算すると値は 1 を超えます。ではそれをファイルに保存するにはどうするか。 8 ビット×3 では足りません — RGBA8 が表せる比は 255 : 1 =8 ストップで、sRGB エンコードで下限を下げても11.7 ストップ止まりです(いちばん暗いコード 1 のリニア値が 3.0353 × 10⁻⁴ なので、その逆数が 3294.6)。屋外の 1 シーンはこれを軽く超えます。

この章では 3 つの入れ物を、仕組みだけ押さえます。

RGBE(Radiance の .hdr)

Greg Ward Larson の Radiance File Formats が定めている形式で、ヘッダーのFORMAT行が32-bit_rle_rgbeまたは32-bit_rle_xyzeでなければ有効な Radiance 画像ではないと書かれています。1 ピクセルは4 バイト— R・G・B の 8 ビット仮数 3 つと、3 つで共有する 8 ビットの指数です。復号は Radiance のソース(src/common/color.hのcx2realとsrc/common/color.cの指数表)を読むとこうなっています。

指数表の添字 1 の値 2.2958874 × 10⁻⁴¹ が 2⁻¹³⁵と一致するのを計算して確かめました。この式で表せる最大値は1.6981 × 10³⁸、最小の非零は 1.1479 × 10⁻⁴¹ で、比は263 ストップです。書き出し側(setcolr)はfrexpを使っていちばん大きい成分の仮数が 128〜255 に入るよう正規化するので、 最大成分の相対精度は 0.4〜0.8% になります。走査線は 4 バイト×幅の生並びのほか、2 種類のランレングス圧縮があり、新しい方式は R・G・B・E の 4 成分を分離してから圧縮します(Radiance の文書は「典型的な圧縮率は 2:1 程度」と書いています)。

RGB9_E5(WebGL2 で使える共有指数形式)

考え方は RGBE と同じで、9 ビット仮数 3 つ + 5 ビット共有指数 = 32 ビット。 ES 3.0.6 §3.8.17 が復号を 値 = 仮数 × 2^(E − B − N)、§3.8.3.2 がN = 9(仮数ビット)・B = 15(指数バイアス)・最大の偏った指数 31 と定めています。ここから、

が出ます。WebGL 2.0 のテクスチャ形式の表に RGB9_E5 があり、転送時のformatはRGB、typeはUNSIGNED_INT_5_9_9_9_REV/ HALF_FLOAT / FLOAT です(色バッファとしては使えません)。

half float

ES 3.0.6 §2.1.2 が定める 16 ビット浮動小数(符号 1 + 指数 5 + 仮数 10)です。最大65504、最小の正規化数 2⁻¹⁴ = 6.1035 × 10⁻⁵、相対精度は2⁻¹¹= 4.883 × 10⁻⁴。正規化数だけで 30 ストップあります。第20章でRGBA16FのレンダーターゲットとEXT_color_buffer_half_floatを扱ったときの「値域が 0〜1 に縛られない入れ物」がこれで、HDR を扱うときの標準的な作業用形式です。ファイルの入れ物(RGBE)と、GPU 上の作業用の入れ物(half float)は別の話だと分けて覚えておくとよいところです。

形式1 ピクセルのビット数全体の比1 ピクセル内の成分比相対精度
RGBA8(リニアとして)328 ストップ8 ストップ暗部で粗い(1/255 一定)
RGBA8(sRGB エンコード)3211.7 ストップ11.7 ストップ暗部が細かい(1 節)
RGBE(.hdr)32263 ストップ9 ストップ0.4〜0.8%(最大成分。書き出し側が仮数を 128〜255 に正規化する)
RGB9_E53240 ストップ9 ストップ0.2〜0.4%(最大成分。仕様の符号化手順が仮数を 256〜511 にする)
half float ×34830 ストップ30 ストップ4.883 × 10⁻⁴

第22章が諦めていたもの

第22章の IBL は、環境マップに public/textures/env/ の 6 枚(px.pngからnz.png)を使っていました。どれも 8 ビットの PNG です。あの章の asideには「本来 IBL は.hdrや.exrのような 1.0 を大きく超える値を持つ画像を使い、太陽が数百〜数千の輝度を持ちます」と書いてあります。 上の表に当てはめると、諦めていたのはこれです。

この章では HDR ファイルを読むローダーは作りません。第4部のデモは テクスチャを持たないハーネス(標準 uniform 3 つ)の上に建っているので、ファイル 入出力を持ち込むと章の焦点がぼやけます。ここでの目的は形式と制約を理解して、上の表を自分で引けるようになることです。RGBE の復号は 4 バイトから 3 つの float を作る数行なので、必要になったときに 書けます(E = 0の分岐を忘れないこと)。

7. 余弦パレット

入れ物の話はここまでです。ここからは、色そのものを設計する側へ移ります。

第26章で「4 つの変奏はどれも『2 色の mixで濃淡を付けるだけ』にしてあります。(…)リニア空間と sRGB の行き来、トーンマッピング、IQ の余弦パレット、バンディングを消すディザリング — それらは第27章でまとめて扱います」と 書きました。ここからがその後半です。

0〜1 のスカラー場から色を作るのに、いちばん少ない部品で済む方法が Inigo Quilez の Palettesにある余弦パレットです。式は 1 本です。

color(t) = a + b · cos(2π(c · t + d))

src/lessons/27-color-and-palette/color.glsl(抜粋)
vec3 palette(float t, vec3 a, vec3 b, vec3 c, vec3 d) {
  return a + b * cos(TAU * (c * t + d));
}

4 つの引数を vec3 にすると、成分ごとに違う波になって色相が動きます。

記事が明記している性質を 2 つ、そのまま引いておきます。

1 つ目は自分でも確かめられます。a = b = 0.5, c = 1,d = (0, 0.33, 0.67)(記事の表の 1 行目)で計算すると、t = 0とt = 1はどちらも (1.0000, 0.2591, 0.2591) で差は 0 です。同じパラメータでc = 0.5にすると周期は 2 になるので、t = 0の(1.0000, 0.2591, 0.2591)に対してt = 1は (0.0000, 0.7409, 0.7409) — 0〜1 では閉じません(同じ色に戻るのはt = 2)。記事の「0.5 の整数倍」は、cosが半周期でa - bからa + bまで色域を往復し切ることを指していると読めます。0〜1 を繰り返しの単位にしたいなら、必要なのは整数の cです。

ついでに、ここで「折れ目が出る」と言ってはいけないことも押さえておきます。a + b·cos(...)は c がどんな値でも何度でも微分できる滑らかな関数なので、素の パレットに折れ目は生じません(記事の「実際には無限階の連続性」がこれです)。問題になるのはfractなどでtを 0〜1 に畳んで使ったときで、そこに現れるのは折れ目ではなくt = 1の色からt = 0の色への跳び(段差)です。上の c = 0.5 の例なら、(0.0000, 0.7409, 0.7409)と (1.0000, 0.2591, 0.2591) の差がそのまま継ぎ目の段差になります。

デモ 3 で目で確かめられるのは、上の 3 段です。帯の tは横位置(左端 0 → 右端 1)なので、cが整数の帯は左端と右端が同じ色になり、非整数の成分を持つ帯(2 段目のc = (1.0, 0.7, 0.4))は端の色が一致しません。ポインタを上下に動かすとcに非整数の倍率が掛かるので、揃っていた端の色がずれていきます(ポインタ未操作の中央では倍率 1.0・位相オフセット 0 で、記事の表の値そのままになるようにしてあります)。

下の区画は t = 中心からの距離 × 1.6 − 時間 × 0.06 で、fractを通していません。それでも縞が閉じるのは、パレット自身が t について周期 1 で閉じているからです(tが 1 増えるごとに同じ色へ戻る = 距離 0.625 ごと)。ここで使っているのはパレット 0 のc = (1, 1, 1)なので、ポインタを 上下に振って倍率を変えても 3 成分の周期は揃ったままで、変わるのは縞の間隔だけです(tについての周期が 1 / 倍率 になります)。

a = b = 0.5なら値域は 0〜1 に収まり、clampは要りません。a + b > 1にすると 1 を超えるので、5 節のトーンマッピングかclampが必要になります。「パレットの設計」と「値域の管理」が同じ 2 つの数字で決まる、というのが この式の使いやすさです。

リニアか、sRGB か

ここは曖昧にできない点です。記事はこの値がリニアなのか sRGB エンコード値なのかを書いていません。この章では 3 節の規則に合わせてリニア値を作るものと決め、出す直前に linearToSrgb を掛けます。

src/lessons/27-color-and-palette/03-cosine-palette.frag(抜粋)
  // リニア値としてのパレット。出す直前にだけ sRGB へ直す
  vec3 linear = palette(t, a, b, c * freq, d + phase);
  vec3 color = linearToSrgb(linear);

どちらに決めるかで見た目は変わります。記事の 1 行目のパレットのt = 0.5はリニア値 (0.0000, 0.7409, 0.7409) で、linearToSrgbを通すと(0.0000, 0.8761, 0.8761)— 成分あたり +0.1352明るくなります。記事に並んでいる見本の帯は「そのまま画面に出した」ものと思われるので、記事の見本と同じ色にしたいなら、パレットの出力を sRGB エンコード値として扱う(= 変換を掛けない)ことになります。この章がリニア側に決めているのは、パレットを ノイズや光と混ぜたときに計算が合うほうを取るためです。混ぜないなら、どちらでもかまいません —決めずに使うことだけが問題です。

3 節で先送りにしたノイズや SDF の値も、同じ理由でリニアと決めています(3 節の表の最後の行)。第24〜26章の距離場・ノイズ・fbm はどれも「単位のない 0〜1」で、光の量でも sRGB エンコード値でもありません。だからどちらとして扱うかは決める側の自由で、 パレットと同じ側 — リニア — に揃えておけば、ノイズで濃淡を作り、パレットで色を付け、光と 足す、という一連の計算が全部同じ空間で閉じます。

8. バンディングとディザリング

最後は出口の 8 ビットです。fragColorに書いた float は、フレームバッファに 入るときに固定小数点へ丸められます。ES 3.0.6 の式 (2.3) がその変換で、値を 0〜1 に clamp してから f × (2ᵇ − 1) を計算し、「b ビットで表せる 2 つの整数のうち近い方(最近傍が望ましい)」を選ぶ、と書かれています。b = 8なら刻みは 1/255 です。

問題は、緩いグラデーションでは 1 段の幅が画面上で広いことです。デモ 4 と同じ条件(描画バッファ幅 1388px = 694 CSS px × dpr 2。実測値は第26章 6 節)で、リニア 0 → 0.02 のグラデーションを全幅に描くと、

グラデーションの上端(リニア)出てくるコード数平均して何 px ごとに 1 段同じコードが並ぶ最長区間
0.012653.4 px80 px
0.024034.7 px62 px
0.045724.4 px46 px
0.088117.1 px35 px

80px も同じ色が続けば、隣との境目は線として見えます。これがバンディング (banding)です。1 節の議論とつながっていて、暗部で目立つ理由は 2 つあります。(1) 暗部ほど 1 段のリニア差が小さいので、同じ幅に入るコード数が少ない。(2) 暗部ほど 1 段の「比」が大きい(コード 1 → 2 は 2.000 倍、254 → 255 は 1.009 倍)。

画面上の位置8 ビットのコード真の値丸めた結果(階段)ディザを足してから丸めた結果
量子化とディザ。丸めるだけだと、真の値(破線)がどこにあっても 1 段のあいだは同じコードになり、境目が線になります。ディザ(点)は段の境目の前後で 2 つのコードを混ぜるので、境目がぼやけて縞が見えなくなります。この図の点は、この節と同じ三角 PDF で実際に計算したものです。

直し方は丸める直前に微小な乱数を足すことです。乱数は 2 つ引いてその差を 使います。三角 PDF (triangular probability density function)— 値域 ±1 で、密度が 0 を頂点とする三角形になる分布です。第25章のpcg3d/hash21をそのまま複製してきて、位置を変えて 2 回引くだけです。

src/lessons/27-color-and-palette/hash.glsl(抜粋)
float triangularDither(ivec2 px) {
  return hash21(px) - hash21(px + ivec2(1237, 9781));
}

hash21はビット演算で 32 ビットの折り返しを使うので、このチャンクを読み込むシェーダーにはprecision highp int;が要ります。理由は第25章 3 節にあるので繰り返しません。

src/lessons/27-color-and-palette/04-dither.frag(抜粋)
precision highp float;
precision highp int; // uint を 32 ビットで扱うため(理由は第25章)

使うときは、8 ビットの 1 段(1/255)を単位にした振幅を掛けます。

src/lessons/27-color-and-palette/04-dither.frag(抜粋)
    // 量子化の直前に、1 段の DITHER_STEPS 倍だけ揺らす。
    // 3 成分に同じ値を足しているので、色ではなく明るさだけが揺れる
    color += triangularDither(ivec2(gl_FragCoord.xy)) * DITHER_STEPS / 255.0;

なぜ乱数を 2 つ引くのか、なぜ振幅が ±1 段なのか。これは好みではなく、 分布から計算で決まります。真の値がコードk + f(0 ≤ f < 1)の一定値の面を考えると、

ここで見るべき指標は 2 つあります。① 面の平均の誤差(縞が残るか)と、② 誤差の分散が f に依存するか(場所によってノイズの粒の強さが変わるか)。分布ごとに解析的に計算すると、こうなります。

足すノイズ① 面の平均の誤差(段)② 誤差の分散(f を動かしたときの範囲)
なし(丸めるだけ)最大 0.50(ノイズがない)
一様 ±0.5 段(乱数 1 つ)00 〜 0.25(信号で変わる)
三角 ±0.5 段最大 0.1250 〜 0.25
三角 ±1 段(この章)00.25 一定
三角 ±1.5 段最大 0.01390.444 〜 0.472
三角 ±2 段00.75 一定

表の 2 行目が大事です。乱数 1 つ(一様 ±0.5 段)でも、面の平均は合います。ではなぜ 2 つ引くのか — 分散が f に依存するからです。f = 0(ちょうどコードに乗っている場所)ではノイズがまったく出ず、f = 0.5で最大になる。つまりグラデーションの上を進むにつれてざらつきが強くなったり消えたりします。これはこれで目につく模様です。三角 ±1 段は平均を合わせたうえで分散も一定 (0.25)にできる分布で、これが乱数を 2 つ引く理由です。三角の中では ±1 段が最小で、±2 段でも成立しますが、ノイズを 3 倍(分散 0.75)にする理由がありません。

代わりに払うものもはっきりしています — 1 ピクセル単位の誤差は増えます。デモ 4 と同じ条件(上端 0.02)で、ディザを掛けた側の 462 行すべてを測ると、同じコードが並ぶ最長区間は 62px から 11〜38px(中位 17px)へ縮み、コードの境目の数は 39 本から 557〜680 本(中位 623 本)へ増えました。ディザは行ごとに 違う乱数を使うので、この 2 つは行によって振れます— そこが「1 本の縞が全行で同じ位置に立つ」ディザなしとの違いです。縞という構造が、ノイズという構造のないものに置き換わった、というのがディザの正体です。

デモ 4 は上下 2 段で、上段だけにディザを掛けています(上段の左上に印を 打ってあります)。同じxなら上下で真の値が同じなので、下段の縞の位置と、上段でそれがどう崩れているかを縦に 見比べられます。ポインタを左右に動かすとグラデーションの上端が変わり、1 つ前の表の 4 行を目で追えます。

コード全文

シェーダー 4 本の抜粋は各節に載せました。全文はこちらです。hash.glslは第25章noise.glslのpcg3d/hash21をそのまま複製したもの(diffで一致を確認済み)に、この章のディザ関数を 1 つ足しただけです。

src/lessons/27-color-and-palette/color.glsl
// 第27章の中核。4 本のデモがすべてこのチャンクを読み込む
// (`#include "color.glsl"` を main.ts が文字列置換する。仕組みは第23章と同じ)。
//
// この 2 つの伝達関数がこのサイトの「色の正本」。第28章・第29章の color.glsl は
// この 2 関数の複製で、内容はバイト単位で同じにしてある(第25→26章の noise.glsl と
// 同じ流儀)。片方を直したら、もう片方も直すこと。
//
// 用語: 「リニア値」= 光の量に比例した数。足し算・掛け算が物理どおりに効く。
//       「sRGB エンコード値」= 画面に出す 0〜1 の目盛り。8 ビットの帯域を
//       暗部へ寄せて配るために、光の量を非線形に変換したもの(1〜2 節)。

/**
 * 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));
}

// ---------------------------------------------------------------------------
// トーンマッピング: 0〜∞ のリニア値を 0〜1 へ写す関数。
// 露出(リニア値への掛け算)とは別物で、必ず sRGB エンコードの「前」に掛ける。
// 順序を逆にすると、1 を超えていた情報が linearToSrgb の clamp で先に消える(5 節)。
// ---------------------------------------------------------------------------

/**
 * Reinhard。x / (1 + x) は 0 → 0 の単調増加で、どんなに大きい入力でも 1 未満に収まる。
 * 出典: Erik Reinhard, Michael Stark, Peter Shirley, James Ferwerda,
 *   "Photographic Tone Reproduction for Digital Images", ACM SIGGRAPH 2002
 *   (ACM Trans. Graph. 21(3), pp.267-276) の式 (3)。
 * ただし論文の式 (3) は「輝度」1 チャンネルに掛けるもので、下のように RGB の
 * 各成分へ別々に掛けるのは論文の手続きではない(理由と影響は本文 5 節)。
 */
vec3 toneMapReinhard(vec3 x) {
  return x / (1.0 + x);
}

/**
 * フィルミックな S 字カーブ。**ACES ではない。**
 * ACES の RRT + ODT の出力を標本して手で当てはめた近似式で、
 * 出典: Krzysztof Narkowicz, "ACES Filmic Tone Mapping Curve" (2016 年 1 月 6 日)
 *   https://knarkowicz.wordpress.com/2016/01/06/aces-filmic-tone-mapping-curve/
 * 記事本人の記述: 最大フィット誤差 0.0138、暗部を合わせるように手で調整、
 * 入力の 1 が出力の約 0.8 に写るよう露出込みで当てはめてある(元の ACES の
 * カーブが欲しければ入力に 0.6 を掛ける)。ライセンスは CC0 または MIT。
 * 記事の UPDATE 節に「輝度だけの単純なフィットなので明部の彩度が上がりすぎる」
 * という本人の注意書きがある。
 */
vec3 toneMapNarkowicz(vec3 x) {
  const float a = 2.51;
  const float b = 0.03;
  const float c = 2.43;
  const float d = 0.59;
  const float e = 0.14;
  return clamp((x * (a * x + b)) / (x * (c * x + d) + e), 0.0, 1.0);
}

// ---------------------------------------------------------------------------
// パレット
// ---------------------------------------------------------------------------

const float TAU = 6.283185307179586;

/**
 * 余弦パレット。color(t) = a + b * cos(2π(c * t + d))
 * 出典: Inigo Quilez, "Palettes" https://iquilezles.org/articles/palettes/
 * 記事が述べていること: 4 つの引数を vec3 にすると色相が動く / 0〜1 で
 * ちょうど一周させたいなら c を 0.5 の整数倍に / C1 連続にしたいなら c を整数に。
 * 記事はこの値がリニアなのか sRGB エンコード値なのかを書いていないので、
 * この章では**リニア値を作るもの**と決めて、出す直前に linearToSrgb を掛ける(7 節)。
 */
vec3 palette(float t, vec3 a, vec3 b, vec3 c, vec3 d) {
  return a + b * cos(TAU * (c * t + d));
}
src/lessons/27-color-and-palette/hash.glsl
// 第25章 src/lessons/25-random-and-noise/noise.glsl からの複製(内容は同一)。
// この章で乱数が要るのは 8 節のディザだけなので、pcg3d と hash21 の 2 つに絞って
// 持ってきた。下の複製部分は 1 文字も変えていない ——
// 第25章のファイルを直したら、ここも直すこと。
//
// このチャンクを使うシェーダーは、先頭に必ず次の 2 行を書くこと。
//   precision highp float;
//   precision highp int;
// 後者がないと uint が 16 ビット実装で別物になる。理由は第25章 3 節。

/**
 * pcg3d — 3 つの uint を、互いに無関係に見える 3 つの uint へかき混ぜる整数ハッシュ。
 * 出典: Mark Jarzynski, Marc Olano, "Hash Functions for GPU Rendering",
 *   Journal of Computer Graphics Techniques, Vol. 9, No. 3 (2020), p.32.
 *   https://jcgt.org/published/0009/03/02/
 * 掛け算と足し算は 2^32 で折り返す(GLSL ES 3.00 §4.1.3)。
 * この桁あふれがビットをかき混ぜる動力そのもので、避けるべき事故ではない。
 */
uvec3 pcg3d(uvec3 v) {
  v = v * 1664525u + 1013904223u;
  v.x += v.y * v.z;
  v.y += v.z * v.x;
  v.z += v.x * v.y;
  v ^= v >> 16u;
  v.x += v.y * v.z;
  v.y += v.z * v.x;
  v.z += v.x * v.y;
  return v;
}

/**
 * 整数の格子座標 → 0〜1 の乱数 1 つ。
 * ivec2 → uvec2 の変換はビットパターンをそのまま移すので(§5.4.1)、負の座標も安全に扱える。
 * float から直接 uint へ変換してはいけない — 負の float から uint への変換は未定義(§5.4.1)。
 */
float hash21(ivec2 cell) {
  uint h = pcg3d(uvec3(ivec3(cell, 0))).x;
  return float(h) / 4294967296.0; // 2^32 で割って 0〜1 へ
}

// --- ここから下は第27章で足したもの(複製ではない) --------------------------

/**
 * 三角 PDF(triangular probability density function)のディザ。
 * 0〜1 の乱数 2 つの差なので値域は ±1 で、密度は 0 を頂点とする三角形になる。
 * 呼び出し側で「8 ビット出力の 1 段 = 1/255」を掛けて、量子化の直前に足す(8 節)。
 * 2 本目のハッシュを引く位置をずらしているのは、同じ座標では同じ値しか返らないため。
 */
float triangularDither(ivec2 px) {
  return hash21(px) - hash21(px + ivec2(1237, 9781));
}
src/lessons/27-color-and-palette/01-linear-vs-srgb.frag
#version 300 es

// 第27章 デモ1: sRGB とリニアの取り違え。
// 画面を 2×2 に分け、**上段が正しい・下段が間違い**にしてある。
// 上段の 2 区画には、見分けるための印を左上に打った。
//   左上 t をリニア値として作り、出す直前に linearToSrgb を掛ける(正しい)
//   左下 t をそのまま sRGB エンコード値として出す(間違い)
//   右上 2 色をリニアで mix してから linearToSrgb を掛ける(正しい)
//   右下 sRGB エンコード値のまま mix する(間違い)
// 左右どちらの列も、**両端の色は上下で完全に同じ**。違うのは真ん中だけ。
// 下段のほうが暗く沈むのは、光の量として見ると足りていないということ。
//
// ポインタ操作は付けていない(色を見比べるには静止画のほうがよい)。

precision highp float;

uniform vec2 u_resolution;

out vec4 fragColor;

#include "color.glsl"

// ---- 書き換えて試すための定数 ----------------------------------------
// 右列で混ぜる 2 色。**リニア値**として書いてある(画面に出る sRGB 値ではない)
const vec3 MIX_A = vec3(1.0, 0.05, 0.0);
const vec3 MIX_B = vec3(0.0, 0.55, 1.0);
// 上段の目印の色と大きさ(px)
const vec3 MARK_COLOR = vec3(0.95, 0.62, 0.28);
const float MARK_RADIUS = 4.0;
// ----------------------------------------------------------------------

void main() {
  // 区画 1 つぶん。全体が 3:2 なので、各区画も 3:2 のまま
  vec2 pane = u_resolution * 0.5;
  vec2 local = mod(gl_FragCoord.xy, pane);
  // x: 0 = 左列 / 1 = 右列、y: 0 = 下段 / 1 = 上段(gl_FragCoord.y は上が大きい)
  ivec2 cell = ivec2(gl_FragCoord.xy / pane);
  bool correct = cell.y == 1;

  float t = local.x / pane.x; // 区画の左端 0 → 右端 1

  vec3 color;
  if (cell.x == 0) {
    // グレーのグラデーション。t は「光の量」= リニア値として作ったつもり
    color = correct ? linearToSrgb(vec3(t)) : vec3(t);
  } else {
    // 2 色の mix。上段はリニアのまま混ぜ、下段は先に sRGB へ直してから混ぜる。
    // t = 0 と t = 1 では両者は完全に一致する(端の色は同じ)
    color = correct
      ? linearToSrgb(mix(MIX_A, MIX_B, t))
      : mix(linearToSrgb(MIX_A), linearToSrgb(MIX_B), t);
  }

  // 上段(正しい側)の左上に印を打つ
  if (correct) {
    vec2 mark = vec2(14.0, pane.y - 14.0);
    color = mix(color, MARK_COLOR, 1.0 - smoothstep(MARK_RADIUS - 1.0, MARK_RADIUS, distance(local, mark)));
  }

  // 区画の境目に細い区切り線を引く(px 単位の距離で太さを決める)
  vec2 toEdge = min(local, pane - local);
  color = mix(vec3(0.02, 0.02, 0.03), color, smoothstep(0.0, 1.5, min(toEdge.x, toEdge.y)));

  fragColor = vec4(color, 1.0);
}
src/lessons/27-color-and-palette/02-exposure-tonemap.frag
#version 300 es

// 第27章 デモ2: 露出とトーンマッピングの比較。
// 画面を縦に 3 分割し、3 帯とも**まったく同じシーン・まったく同じ露出**で描く。
// 違うのは「0〜∞ のリニア値を 0〜1 へ写す関数」だけ。
//   左   clamp            — 1 を超えたぶんを切り落とす
//   中   Reinhard         — x / (1 + x)
//   右   Narkowicz の近似 — ACES 風のフィルミック曲線(ACES 本体ではない)
// 帯の下端の点の数が、この並び順。
//
// シーンは第18章と同じ「2 灯 + 逆二乗減衰」。リニア値は灯の中心で 12.5、いちばん遠い隅で
// 0.021 まで落ちる(比で約 600 倍 = 9.2 ストップ)。1.0 を超えるのは帯の面積の 3.7% ほどで、
// 露出 0 ストップなら**形が失われるのはそこだけ**(明るさは 3 帯とも全域で違う。本文 5 節の表)。
//
// ポインタ: 左右で露出 -3〜+3 ストップ(×0.125〜×8)。未操作(中央)では 0 ストップ。

precision highp float;

uniform vec2 u_resolution;
uniform vec2 u_mouse;

out vec4 fragColor;

#include "color.glsl"

// ---- 書き換えて試すための定数 ----------------------------------------
// 2 灯の位置(帯ローカルの標準座標)と色。色は**リニア値**
const vec2 LIGHT1_POSITION = vec2(-0.32, 0.62);
const vec3 LIGHT1_COLOR = vec3(1.0, 0.62, 0.26);
const vec2 LIGHT2_POSITION = vec2(0.34, -0.62);
const vec3 LIGHT2_COLOR = vec3(0.30, 0.58, 1.0);
const float LIGHT_INTENSITY = 0.05;
// 逆二乗が原点で発散するのを止める下限(第18章の減衰と同じ手当て)
const float FALLOFF_EPSILON = 0.004;
// 露出の範囲(ストップ = 2 の指数)
const float EXPOSURE_MIN = -3.0;
const float EXPOSURE_MAX = 3.0;
// 背景の基調色。第4部の規約の値は「画面に出る sRGB 値」なので、
// リニアで足すために srgbToLinear を通す(3 節)
const vec3 BACKGROUND_SRGB = vec3(0.06, 0.07, 0.09);
// 帯の数と、下端の目印
const int BANDS = 3;
const vec3 MARK_COLOR = vec3(0.95, 0.62, 0.28);
// ----------------------------------------------------------------------

/** 2 灯の寄与をリニア空間で足す。これが 1 を超えるのが 4〜5 節の主題 */
vec3 lightSum(vec2 p) {
  vec2 d1 = p - LIGHT1_POSITION;
  vec2 d2 = p - LIGHT2_POSITION;
  return LIGHT_INTENSITY
    * (LIGHT1_COLOR / (dot(d1, d1) + FALLOFF_EPSILON)
     + LIGHT2_COLOR / (dot(d2, d2) + FALLOFF_EPSILON));
}

void main() {
  float bandWidth = u_resolution.x / float(BANDS);
  float bandF = gl_FragCoord.x / bandWidth;
  int band = int(bandF);

  // 帯の中だけで標準座標を作る。3 帯が同じ絵になるので、違いは関数だけになる
  vec2 pane = vec2(bandWidth, u_resolution.y);
  vec2 local = vec2(gl_FragCoord.x - float(band) * bandWidth, gl_FragCoord.y);
  vec2 p = (local * 2.0 - pane) / min(pane.x, pane.y);

  float stops = mix(EXPOSURE_MIN, EXPOSURE_MAX, u_mouse.x / u_resolution.x);
  float exposure = exp2(stops); // 1 ストップ = 2 倍

  // ここまでずっとリニア。露出は「リニア値への掛け算」なのでトーンマップより先
  vec3 hdr = (srgbToLinear(BACKGROUND_SRGB) + lightSum(p)) * exposure;

  vec3 mapped;
  if (band == 0) {
    mapped = clamp(hdr, 0.0, 1.0);
  } else if (band == 1) {
    mapped = toneMapReinhard(hdr);
  } else {
    mapped = toneMapNarkowicz(hdr);
  }

  // 0〜1 に畳んでから、最後に sRGB へ。この順序が肝(5 節)
  vec3 color = linearToSrgb(mapped);

  // 帯の境目に細い区切り線を引く
  float toEdge = min(fract(bandF), 1.0 - fract(bandF)) * bandWidth;
  color = mix(vec3(0.02, 0.02, 0.03), color, smoothstep(0.0, 1.5, toEdge));

  // 帯の下端に、左から 1・2・3 個の目印を打つ
  float pitch = 11.0;
  float startX = bandWidth * 0.5 - pitch * 0.5 * float(band);
  float marks = 0.0;
  for (int i = 0; i < BANDS; i++) {
    if (i > band) {
      break;
    }
    vec2 center = vec2(startX + pitch * float(i), 16.0);
    marks = max(marks, 1.0 - smoothstep(2.5, 3.5, distance(local, center)));
  }
  color = mix(color, MARK_COLOR, marks);

  fragColor = vec4(color, 1.0);
}
src/lessons/27-color-and-palette/03-cosine-palette.frag
#version 300 es

// 第27章 デモ3: Inigo Quilez の余弦パレット。
//   color(t) = a + b * cos(2π(c * t + d))
// 上の 3 段は、記事 https://iquilezles.org/articles/palettes/ の表から 3 行を
// そのまま持ってきたもの。t は横位置(左端 0 → 右端 1)。
// 下の大きな区画は、いちばん上のパレットを t = 中心からの距離 に当てたもの。
// c が整数のパレットは t の周期が 1 なので、**t が 1 増えるごとに**同じ色へ戻る
// (FIELD_SCALE = 1.6 を掛けているので、距離では 1/1.6 = 0.625 ごと)。
// fract を挟まなくても縞が閉じるのは、そのため。
//
// ポインタ: 左右で d に足すオフセット(位相を回す)、上下で c に掛ける倍率。
// 未操作(中央)では オフセット 0・倍率 1.0 = **記事の表の値そのまま**になる。
// 倍率を動かすと、下の区画では**縞の間隔が変わる**(t についての周期が 1/倍率 になる。
// パレットは任意の c について滑らかなので、ここに不連続や折れ目は出ない)。
// **c が整数かどうかが効くのは上の 3 段**で、t = 横位置 ∈ [0, 1] なので、
// c が整数の帯だけ左端と右端が同じ色になる。倍率を整数から外すと端の色がずれる。
//
// パレットの出力は**リニア値**として扱い、出す直前に linearToSrgb を掛ける(7 節)。

precision highp float;

uniform float u_time;
uniform vec2 u_resolution;
uniform vec2 u_mouse;

out vec4 fragColor;

#include "color.glsl"

// ---- 書き換えて試すための定数 ----------------------------------------
// 画面の下から何割を「距離に当てた区画」にするか
const float FIELD_FRACTION = 0.55;
// 下の区画で使うパレットの番号(0 = いちばん上の帯)
const int FIELD_PALETTE = 0;
// 距離 1.0 を何周期ぶんにするか / 縞が外へ流れる速さ
const float FIELD_SCALE = 1.6;
const float FIELD_SPEED = 0.06;
// ポインタで動かす範囲。どちらも中央が「記事の値そのまま」になるように取ってある
const float PHASE_MIN = -0.5;
const float PHASE_MAX = 0.5;
const float FREQ_MIN = 0.25;
const float FREQ_MAX = 1.75;
// ----------------------------------------------------------------------

/**
 * 記事の表から 3 行。a と b が 0.5 の行は値域がちょうど 0〜1 になり、
 * 3 行目のように a と b を変えると値域が a ± b に狭まる(どちらも clamp は不要)。
 */
void paletteParams(int index, out vec3 a, out vec3 b, out vec3 c, out vec3 d) {
  if (index == 0) {
    a = vec3(0.5, 0.5, 0.5);
    b = vec3(0.5, 0.5, 0.5);
    c = vec3(1.0, 1.0, 1.0);
    d = vec3(0.00, 0.33, 0.67);
  } else if (index == 1) {
    a = vec3(0.5, 0.5, 0.5);
    b = vec3(0.5, 0.5, 0.5);
    c = vec3(1.0, 0.7, 0.4);
    d = vec3(0.00, 0.15, 0.20);
  } else {
    a = vec3(0.8, 0.5, 0.4);
    b = vec3(0.2, 0.4, 0.2);
    c = vec3(2.0, 1.0, 1.0);
    d = vec3(0.00, 0.25, 0.25);
  }
}

void main() {
  vec2 mouse01 = u_mouse / u_resolution;
  float phase = mix(PHASE_MIN, PHASE_MAX, mouse01.x);
  float freq = mix(FREQ_MIN, FREQ_MAX, mouse01.y);

  float fieldHeight = u_resolution.y * FIELD_FRACTION;
  float stripHeight = (u_resolution.y - fieldHeight) / 3.0;

  int index;
  float t;
  if (gl_FragCoord.y < fieldHeight) {
    // 下の区画: 中心からの距離をそのまま t にする
    vec2 pane = vec2(u_resolution.x, fieldHeight);
    vec2 p = (gl_FragCoord.xy * 2.0 - pane) / min(pane.x, pane.y);
    index = FIELD_PALETTE;
    t = length(p) * FIELD_SCALE - u_time * FIELD_SPEED;
  } else {
    // 上の 3 段: 上から 0・1・2。t は横位置
    int fromBottom = int((gl_FragCoord.y - fieldHeight) / stripHeight);
    index = 2 - clamp(fromBottom, 0, 2);
    t = gl_FragCoord.x / u_resolution.x;
  }

  vec3 a, b, c, d;
  paletteParams(index, a, b, c, d);

  // リニア値としてのパレット。出す直前にだけ sRGB へ直す
  vec3 linear = palette(t, a, b, c * freq, d + phase);
  vec3 color = linearToSrgb(linear);

  // 3 本の水平な境目に細い区切り線を引く(px 単位の距離で太さを決める)
  float toEdge = abs(gl_FragCoord.y - fieldHeight);
  toEdge = min(toEdge, abs(gl_FragCoord.y - (fieldHeight + stripHeight)));
  toEdge = min(toEdge, abs(gl_FragCoord.y - (fieldHeight + stripHeight * 2.0)));
  color = mix(vec3(0.02, 0.02, 0.03), color, smoothstep(0.0, 1.5, toEdge));

  fragColor = vec4(color, 1.0);
}
src/lessons/27-color-and-palette/04-dither.frag
#version 300 es

// 第27章 デモ4: バンディングとディザリング。
// 横方向に、リニア 0 → 上端(TOP_MIN〜TOP_MAX)の**とても緩い**グラデーションを描く。
// 上下 2 段に分け、**上段だけ**ディザを足す(上段の左上に印を打った)。
// 同じ x なら上下で真の値が同じなので、下段に出る縞の位置と、
// 上段でそれがどう崩れているかを、縦に見比べられる。
//
// 「ディザなし」は間違いではなく、**何もしないと縞が出る**という素の状態。
// 縞が見えているのは 8 ビットの刻みそのもので、シェーダーのバグではない(8 節)。
//
// ポインタ: 左右でグラデーションの上端(リニア 0.005〜0.08)。
// 未操作(中央)では 0.0425 付近。上端を下げるほど、同じ幅に入るコード数が減って
// 1 段あたりの帯が太くなり、縞がはっきりする。

precision highp float;
precision highp int; // uint を 32 ビットで扱うため(理由は第25章)

uniform vec2 u_resolution;
uniform vec2 u_mouse;

out vec4 fragColor;

#include "color.glsl"
#include "hash.glsl"

// ---- 書き換えて試すための定数 ----------------------------------------
// グラデーションの色味。リニア値として掛ける
const vec3 TINT = vec3(0.62, 0.72, 1.0);
// グラデーションの上端(リニア)をポインタで動かす範囲
const float TOP_MIN = 0.005;
const float TOP_MAX = 0.08;
// ディザの振幅。8 ビット出力の 1 段を単位にした値。
// 三角 PDF で ±1 段が、面の平均を真の値に一致させる最小の振幅(8 節)
const float DITHER_STEPS = 1.0;
// 上段の目印
const vec3 MARK_COLOR = vec3(0.95, 0.62, 0.28);
const float MARK_RADIUS = 4.0;
// ----------------------------------------------------------------------

void main() {
  float t = gl_FragCoord.x / u_resolution.x;
  float top = mix(TOP_MIN, TOP_MAX, u_mouse.x / u_resolution.x);
  bool dithered = gl_FragCoord.y > u_resolution.y * 0.5;

  // 設計はリニア。ここまでは上下段でまったく同じ値
  vec3 linear = TINT * (top * t);
  vec3 color = linearToSrgb(linear);

  if (dithered) {
    // 量子化の直前に、1 段の DITHER_STEPS 倍だけ揺らす。
    // 3 成分に同じ値を足しているので、色ではなく明るさだけが揺れる
    color += triangularDither(ivec2(gl_FragCoord.xy)) * DITHER_STEPS / 255.0;

    // ディザを掛けた側だと分かるように、左上に印を打つ
    vec2 mark = vec2(14.0, u_resolution.y - 14.0);
    color = mix(color, MARK_COLOR, 1.0 - smoothstep(MARK_RADIUS - 1.0, MARK_RADIUS, distance(gl_FragCoord.xy, mark)));
  }

  // 上下の境目に細い区切り線を引く
  float toEdge = abs(gl_FragCoord.y - u_resolution.y * 0.5);
  color = mix(vec3(0.02, 0.02, 0.03), color, smoothstep(0.0, 1.5, toEdge));

  fragColor = vec4(color, 1.0);
}
src/lessons/27-color-and-palette/main.ts
// 第27章: 色とパレット
// 第2部と同じ「1 canvas + 切替ボタン」構成(第10章の main.ts をそのまま踏襲)。
// 第25〜26章と同じく、共通のチャンクを #include で配るので、
// 送る前に文字列置換で解決する。

import { startFullscreenShader } from '../../lib/fullscreen-shader';
import linearVsSrgbSource from './01-linear-vs-srgb.frag?raw';
import exposureTonemapSource from './02-exposure-tonemap.frag?raw';
import cosinePaletteSource from './03-cosine-palette.frag?raw';
import ditherSource from './04-dither.frag?raw';
import colorChunkSource from './color.glsl?raw';
import hashChunkSource from './hash.glsl?raw';

// 第23章と同じ極小のインクルード。置換文字列を関数で渡しているのは、
// String.replace が `$&` などを特別扱いするため。
// color.glsl が先(TAU を定義しているのはこちら)。hash.glsl はディザだけが使う。
function resolveIncludes(source: string): string {
  return source
    .replace('#include "color.glsl"', () => colorChunkSource.trim())
    .replace('#include "hash.glsl"', () => hashChunkSource.trim());
}

const demos = [
  { id: 'linear-vs-srgb', label: '取り違え 正/誤', source: resolveIncludes(linearVsSrgbSource) },
  { id: 'exposure', label: '露出とトーンマップ', source: resolveIncludes(exposureTonemapSource) },
  { id: 'palette', label: '余弦パレット', source: resolveIncludes(cosinePaletteSource) },
  { id: 'dither', label: 'ディザ あり/なし', source: resolveIncludes(ditherSource) },
] as const;

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

let handle = startFullscreenShader(canvas, demos[0].source);

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', () => {
    // いまのループを止めてから、新しいシェーダーで開始し直す(第6章の stop() の出番)。
    // 古いプログラムの解放は省略している(後始末は第35章)
    handle.stop();
    handle = startFullscreenShader(canvas, demo.source);
    for (const b of controls.querySelectorAll('button')) {
      b.setAttribute('aria-pressed', 'false');
    }
    button.setAttribute('aria-pressed', 'true');
  });
  controls.append(button);
}

three.js との対応

色の管理は three.js がいちばん多くを引き受けている領域です。下の表はthree.js r185 のソースを開いて確認した内容だけを書いています(バージョンに よって既定値が変わってきた経緯があるので、確認できていない版の話はしません)。

three.js (r185)この章
THREE.ColorManagement— enabled = true、workingColorSpace = LinearSRGBColorSpace「間はすべてリニア」を自分で守る(3 節)。LinearSRGBColorSpaceもSRGBColorSpaceも原色は同じREC709_PRIMARIES・白色点 D65 で、違いは伝達関数だけ
WebGLRenderer.outputColorSpace— 初期値は SRGBColorSpace出口の linearToSrgb 1 行(3 節)。r185 のシェーダーチャンクsRGBTransferOETFはmix+lessThanEqualで書かれていて、この章のstep版と同じ式(指数は 0.41666)
texture.colorSpace— 初期値は NoColorSpace。色画像にはSRGBColorSpaceを指定する入口の srgbToLinear(3 節)。第16章が「この章では素通し」と書いて 送ってきたところ
SRGB8_ALPHA8の自動選択 — WebGLTextures のgetInternalFormatが、8 ビット + sRGB 伝達関数のときこの内部形式を選ぶ同じ選択肢を手で選ぶ(3 節)。GPU 側の変換式は ES 3.0.6 §3.8.16 =srgbToLinearと同一
WebGLRenderer.toneMapping— 初期値は NoToneMapping。定数はLinear/Reinhard/Cineon/ACESFilmic/Custom/AgX/Neutralこの章は 3 つ(clamp / Reinhard / Narkowicz の近似)を帯で並置(5 節)
ReinhardToneMappingの実装 — saturate(color / (1 + color)) を成分ごとにtoneMapReinhardも同じ per-channel 版。論文の式 (3) は輝度に掛けるものという違いは 5 節に書いた(three.js のソースも出典として UUCS-02-01 の URL を挙げている)
ACESFilmicToneMapping— AP1 への行列 2 つ + RRTAndODTFit(出典は selfshadow / Stephen Hill のltc_code)+ 露出に / 0.6この章の toneMapNarkowicz は別のフィット(5 つの係数の有理関数)。どちらも ACES 本体ではないが、係数も出典も違うので同じ絵にはならない
AgXToneMapping— Filament 経由の移植。シグモイドはagxDefaultContrastApprox(6 次多項式)曲線は実装しない。体系(Sobotka / Blender)と GLSL のフィット(Filament 経由 / Wrensch)の区別だけ 5 節に書いた
toneMappingExposure — 初期値 1.0exp2(stops)をリニア値に掛ける(5 節)。three.js も各トーンマップ関数の先頭でcolor *= toneMappingExposureを掛けている
Material.dithering— 既定 false。有効にすると #define DITHERING が入り、dithering_pars_fragmentのmix(2·shift, -2·shift, rand())(shift = ±0.25/255)が足される振幅は一様 ±0.5 段で、8 節の分布の表の 2 行目にあたる(R と B は同符号、G は逆符号)。掛ける位置はtonemapping → colorspace → … → ditheringの順でcolorspace の後— 8 節の「量子化の直前 = linearToSrgb の後」という規則と同じ。この章はhash.glslの三角 PDF ±1 段を出口で足す(8 節)

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

この章のコードは src/lessons/27-color-and-palette/ にあります。color.glslは 4 本のデモすべてに効くので、まずそこから。

まとめ

これで、値を作る道具(第24〜26章)と、それを色にして出す道具(この章)が揃いました。 残っているのは形を 3 次元に持ち上げることです。次章第28章「レイマーチング」では、第24章の距離関数を 3D に拡張し、カメラから 1 本ずつレイを飛ばして、距離のぶんだけ 進みながら面を探します。三角形を 1 枚も並べずに立体を描く方法で、法線は距離関数の数値微分から 作り、影もアンビエントオクルージョンも「距離を測る」だけで手に入ります。第8章から預けてきた 「3D SDF・レイマーチング」の伏線も、そこで回収します。