この記事が扱う症状
以下に心当たりがある人向けです。
- Ghostty に
custom-shaderを設定したら、背景は綺麗になったが文字が読めない - 文字が微妙に波打つ、にじんで見える(
water.glsl系のシェーダー) - 黒背景が灰色や緑に持ち上がって、コントラストが落ちる(
noise.glsl/mnoise.glsl系) minimum-contrastを上げても何も変わらない- dev.to の記事どおりに
noise.glslを取ろうとすると 404 が返る - config に
Transparency 14やBlur ONと書いてあって、起動時にエラーになる
結論を先に書きます。custom-shader はポストプロセスです。 描画済みのターミナル画面が iChannel0 として渡ってくるので、minimum-contrast のようなターミナル側の設定はシェーダーより前に評価され、届きません。可読性を戻すならシェーダー側で、エフェクトを背景ピクセルにだけ乗せるのが正解です。
前座: iTerm2のUI表記が混ざったconfig
そもそもの発端は、起動のたびに config のエラーが出ることでした。原因は2行です。
Transparency 14
Blur ONiTerm2 の設定画面の表記を、メモのつもりで書き写して行頭の # を忘れたものでした。Ghostty の config は key = value 形式しか受け付けず、Transparency も Blur も存在しないキーなので、そのまま読み込みエラーになります。透明度とブラーの実体は直後の background-opacity / background-blur が担っていたので、この2行はコメント化するだけで済みました。
+validate-config が exit 0 で通るか、で確認できます。ここまでは前座です。
シェーダー背景を入れたら、文字が読めなくなった
config が直ったので、ついでにフラグメントシェーダーを背景にすることにしました。出典は hackr-sh/ghostty-shaders です。
ここで最初のつまずき。紹介記事にある noise.glsl は、今このリポジトリに存在しません。 mnoise.glsl にリネームされています(中身は simplex noise で、記事のスクショにあるオーロラ状の層はこれです)。raw の URL を叩いても 404 なので、記事のとおりに進めると最初のダウンロードで止まります。
mnoise.glsl と water.glsl を取ってきて、config に追記しました。
custom-shader = shaders/mnoise.glsl
custom-shader = shaders/water.glsl
custom-shader-animation = true⌘⇧, (reload_config)でリロード。オーロラと水面のリップルが両方出て、背景は狙いどおりになりました。そして文字が読めなくなりました。

雰囲気は最高です。ただ、左のファイルツリーもコメント行も、緑がかった帯に溶けかけています。これで一日コードを読む気にはなれません。
最初の処方は、外れだった
真っ先に出した処方は minimum-contrast = 1.1 でした。背景色に対して文字色を自動で持ち上げてくれる設定で、名前からして今回の症状にぴったりに見えます。
入れて、リロードして、何も変わりませんでした。
理由はシェーダーの立ち位置にあります。custom-shader はポストプロセス、つまりターミナルの描画がすべて終わったあとの画面を1枚の画像として受け取り、加工して出力します。シェーダーの中の iChannel0 がその画面です。
vec4 src = texture(iChannel0, uv); // ← 描画済みのターミナル画面一方 minimum-contrast は、Ghostty が文字を描く段階で文字色と背景色を比較して働きます。シェーダーが後から背景を明るくした分は、その比較に入っていません。 設定としては正しく効いているのに、効かせたい場所に届いていない。
設定値が正しいのに現象が直らないときは、たいていこのパターンです。
真犯人は、シェーダーの合成方法だった
登場人物を見直すと、犯人はシェーダーの最後の1行にいました。2本のシェーダーが、別々のやり方で可読性を削っていたのです。
mnoise.glsl — 加算合成
fragColor = vec4(ghosttyCol.rgb + mix(col4, fcol, ...), ghosttyCol.a);
// ^ 加算ターミナルの色に足し算しています。黒背景が灰緑に持ち上がり、暗い文字色との差が潰れる。背景が明るくなるのではなく、画面全体が持ち上がるのがポイントです。
water.glsl — UVずらし
vec2 tc = vec2(cos(c)-0.75,sin(c)-0.75)*0.04;
uv = clamp(uv + tc,0.0,1.0);
fragColor = texture(iChannel0, uv); // ← ずらした座標で画面をサンプリングこちらは色ではなくサンプリング座標そのものをずらしています。水面感の正体はこれですが、iChannel0 には文字も入っているので、グリフごと歪みます。 文字がゆらいで見えたのはこのせいでした。
当座の処置として、mnoise.glsl の加算量に STRENGTH = 0.35 を掛け、water.glsl の *0.04 を *0.01 に下げました。これで読めるようにはなります。ただ、どちらも「効きすぎているエフェクトを薄める」だけの対症療法で、文字に手が及んでいる構造は変わっていません。
本命: グリフマスク
そこで、2本を捨てて1本書き直しました。方針はひとつだけです。
輝度でグリフを判定し、エフェクトを背景ピクセルにだけ乗せる。
vec4 src = texture(iChannel0, uv);
// 輝度でグリフを判定する。bg = 1.0 が背景、0.0 が文字。
float lum = dot(src.rgb, vec3(0.299, 0.587, 0.114));
float bg = 1.0 - smoothstep(GLYPH_LO, GLYPH_HI, lum);
...
// bg を掛けているので、文字の上にはプール色が乗らない
fragColor = vec4(src.rgb + pool * bg, src.a);dot(src.rgb, vec3(0.299, 0.587, 0.114)) は輝度(明るさ)を出す定番の式です。暗ければ背景、明るければ文字。smoothstep で境界をなだらかにしているので、アンチエイリアスの効いた文字の縁でも段差になりません。あとは bg を掛けるだけで、エフェクトは文字を避けて背景にだけ乗ります。
加えて UV ずらしを完全に廃止しました。これで歪みの経路も消えます。「背景が持ち上がる」「グリフが波打つ」という2つの劣化要因が、両方なくなりました。
背景側は水中の雰囲気を作る4要素で構成しています。コースティクス(水面から差し込む光の網)、水の色、深さによる減光、周辺減光。定数を頭にまとめて出してあるので、見た目はそこだけで振れます。
const float SPEED = 0.45; // 水面の揺れる速さ
const float CAUSTIC_STRENGTH = 0.40; // 光の網の濃さ
const float CAUSTIC_SHARP = 10.0; // 網の細さ
const float TINT_STRENGTH = 0.35; // 水の色の濃さ
const float DEPTH_STRENGTH = 0.45; // 下にいくほど暗くなる量
const float VIGNETTE = 0.55; // 周辺減光の強さ
const float GLYPH_LO = 0.10; // これ以下の輝度を背景とみなす
const float GLYPH_HI = 0.30; // これ以上の輝度を文字とみなす
同じ「水の中」なのに、文字が背景から浮き上がっています。リロードして画面を見た本人の第一声が、この方式の説明として一番正確でした。
これは逆に、文字を浮き出して背景でプールを演出したんですね。
そのとおりです。文字は Ghostty が描いたものがそのまま残り、背景だけが水になる。シェーダーで可読性を確保するのではなく、シェーダーを可読性の外に置く、という考え方です。
GLYPH_LO / GLYPH_HI はカラースキームを変えたときだけ触る値です。明るい背景のテーマを使っているなら、判定を反転させる必要があります。
ついでに分かったこと: 透過している限り、鮮やかな色は出せない
仕上げに色を詰めているとき、こんな注文が出ました。
車の中のアンビエントに使われるような彩度の青がいいかな
やってみて、出ませんでした。WATER_TINT を青主軸に振り、輝度を保ったまま彩度を上げる SATURATION ノブまで足しても、べったりした青になるだけで「ほぼ黒地に鮮やかな青が浮く」感じにならない。
理由は透過です。background-opacity = 0.85 にしているので、シェーダーの出力はアルファ 0.85 のまま OS に渡され、デスクトップとの合成はシェーダーより後で行われます。
最終画素 = 0.85 × シェーダー出力 + 0.15 × 壁紙壁紙の明るい部分が 15% 混ざる。これは明度を上げるだけでなく、彩度も薄めます。 シェーダー側でいくら彩度を上げても、この 15% が白っぽさを混ぜ返してくる。背景に別の絵が透けている限り、原理的に「ほぼ黒地に鮮やかな色」は出せません。

狙った色にならないどころか、SATURATION が光の網まで青く染めてしまい、水面感の正体だった明暗差まで消えました。彩度版は結局そのまま破棄しました。元の青緑(vec3(0.06, 0.30, 0.42))は緑が乗っているぶん「水を通した光」に見えていて、そちらのほうが素直に綺麗だった、というオチです。
なお、この手の試行錯誤はコミットせずに作業ツリーだけで試すと、git checkout -- shaders/pool.glsl 一発で気に入っていた状態に戻れます。見た目の調整はコミット粒度を細かくするより、この使い方のほうが合っていました。
教訓
設定が効かないときは、その設定が評価される「順番」を見る。 minimum-contrast は正しく動いていました。ただ、シェーダーが背景を明るくするより前に仕事を終えていた。値を上げ下げして粘るのではなく、パイプラインのどこに挟まっているかを確認するのが早道です。
ポストプロセスのシェーダーは、文字と背景を区別しない。 iChannel0 に入ってくるのは合成済みの1枚の画像で、そこに「ここは文字」という情報はありません。素直に書けば文字ごと加工されます。区別したいなら輝度なりで自分で判定するしかなく、逆に言えば判定さえすれば区別できます。
個人ブログのシェーダー紹介記事は、リンク先のリネームまで面倒を見てくれない。 noise.glsl → mnoise.glsl のように、上流が動いている前提で読んだほうがいい。記事を疑う前に、まず今のリポジトリのファイル一覧を見るのが確実です。

