2855 文字
14 分
Ghosttyのcustom-shaderで文字が読めない — minimum-contrastが効かない理由と、背景だけに効かせるグリフマスク

この記事が扱う症状#

以下に心当たりがある人向けです。

  • Ghostty に custom-shader を設定したら、背景は綺麗になったが文字が読めない
  • 文字が微妙に波打つ、にじんで見える(water.glsl 系のシェーダー)
  • 黒背景が灰色や緑に持ち上がって、コントラストが落ちる(noise.glsl / mnoise.glsl 系)
  • minimum-contrast を上げても何も変わらない
  • dev.to の記事どおりに noise.glsl を取ろうとすると 404 が返る
  • config に Transparency 14Blur ON と書いてあって、起動時にエラーになる

結論を先に書きます。custom-shader はポストプロセスです。 描画済みのターミナル画面が iChannel0 として渡ってくるので、minimum-contrast のようなターミナル側の設定はシェーダーより前に評価され、届きません。可読性を戻すならシェーダー側で、エフェクトを背景ピクセルにだけ乗せるのが正解です。

前座: iTerm2のUI表記が混ざったconfig#

そもそもの発端は、起動のたびに config のエラーが出ることでした。原因は2行です。

Transparency 14
Blur ON

iTerm2 の設定画面の表記を、メモのつもりで書き写して行頭の # を忘れたものでした。Ghostty の config は key = value 形式しか受け付けず、TransparencyBlur も存在しないキーなので、そのまま読み込みエラーになります。透明度とブラーの実体は直後の background-opacity / background-blur が担っていたので、この2行はコメント化するだけで済みました。

+validate-config が exit 0 で通るか、で確認できます。ここまでは前座です。

シェーダー背景を入れたら、文字が読めなくなった#

config が直ったので、ついでにフラグメントシェーダーを背景にすることにしました。出典は hackr-sh/ghostty-shaders です。

ここで最初のつまずき。紹介記事にある noise.glsl は、今このリポジトリに存在しません。 mnoise.glsl にリネームされています(中身は simplex noise で、記事のスクショにあるオーロラ状の層はこれです)。raw の URL を叩いても 404 なので、記事のとおりに進めると最初のダウンロードで止まります。

mnoise.glslwater.glsl を取ってきて、config に追記しました。

custom-shader = shaders/mnoise.glsl
custom-shader = shaders/water.glsl
custom-shader-animation = true

⌘⇧, (reload_config)でリロード。オーロラと水面のリップルが両方出て、背景は狙いどおりになりました。そして文字が読めなくなりました。

mnoise.glsl と water.glsl を重ねた状態。背景は綺麗だが、文字が背景に沈んで読みにくい

雰囲気は最高です。ただ、左のファイルツリーもコメント行も、緑がかった帯に溶けかけています。これで一日コードを読む気にはなれません。

最初の処方は、外れだった#

真っ先に出した処方は 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;  // これ以上の輝度を文字とみなす

pool.glsl に差し替えた状態。背景は水中のまま、文字はくっきり読める

同じ「水の中」なのに、文字が背景から浮き上がっています。リロードして画面を見た本人の第一声が、この方式の説明として一番正確でした。

これは逆に、文字を浮き出して背景でプールを演出したんですね。

そのとおりです。文字は 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.glslmnoise.glsl のように、上流が動いている前提で読んだほうがいい。記事を疑う前に、まず今のリポジトリのファイル一覧を見るのが確実です。

この記事は AI(Claude) を活用して書いています
アイキャッチ画像は ComfyUI + Flux.1 で生成しています
Ghosttyのcustom-shaderで文字が読めない — minimum-contrastが効かない理由と、背景だけに効かせるグリフマスク
https://oranges-blog.netlify.app/posts/ghostty-custom-shader-readability/
作者
orangeVaper
公開日
2026-09-18
ライセンス
CC BY-NC-SA 4.0