3198 文字
16 分
Ghosttyのカーソルシェーダーで波紋を描く — iPreviousCursorで作れる水切りの石と、作れない連打の波紋

前回までは、Ghostty の custom-shader で背景を作っていました。今回は、カーソルの動きに反応するシェーダーを作っています。

新しいshaderの開発をしたいと思うんですが、なにがいいかなー

orangeVaper

手元にあったのは、放っておいても動く背景(プール、マーブル、煙、インク、水中の光)と、カーソルが動いたときに出る光の尾と火花です。背景がこちらの操作に反応するものは、まだありませんでした。そこで、打鍵した位置から波紋が広がるシェーダーを作るところから始めています。

最終的には、次の3本になりました。

ファイル中身
rain_ripple.glsl画面のあちこちで雨粒のような波紋が湧く背景。波紋が通ると文字も揺れる
rain_cursor_big.glsl打鍵した位置から波紋が広がる。カーソルのまわりは揺らさない
stone_skip.glslカーソルが大きく動いたとき、水切りの石が跳ねて着水点ごとに波紋を残す

作れるものと作れないものの境目は、Ghostty がシェーダーに渡すカーソルの情報で決まっていました。

シェーダーが受け取れるカーソルの情報#

Ghostty の custom-shader は、Shadertoy 互換の iTime や iResolution に加えて、カーソル用の値を受け取れます。

値中身
iCurrentCursor今のカーソルの位置と大きさ(xy がセルの左上、zw が幅と高さ)
iPreviousCursor1つ前のカーソルの位置と大きさ
iTimeCursorChange最後にカーソルが動いた時刻

カーソルの中心は、既存の mode-glow.glsl と同じ式で取っています。

vec2 cursorCenter(vec4 cursor) {
    return cursor.xy + vec2(cursor.z * 0.5, -cursor.w * 0.5);
}
IMPORTANT

シェーダーは毎フレーム一から描き直され、前のフレームの絵を覚えておく場所がありません。 Shadertoy の Buffer A のような、前のフレームを読み返す仕組みは使えません。2つ前より昔のカーソル移動が、いつどこで起きたかは分かりません。

打鍵で広がる波紋#

最初の ripple.glsl は、iTimeCursorChange からの経過時間で輪の半径を広げ、iCurrentCursor の位置を中心に描くものです。輪の色を背景に足すだけで、文字には触れません。

float t = iTime - iTimeCursorChange;
float progress = t / DURATION;
float radius = easeOut(progress) * MAX_RADIUS;

動きはしましたが、波紋は常に1つしか出ません。次の打鍵で iTimeCursorChange が更新されると、前の波紋は消えて最初からやり直しになります。このシェーダーは使わないことにして、プリセットからは外しました。

この段階で、プリセットの構成も変えています。それまでは背景とカーソルのエフェクトを1つのファイルにまとめていたため、背景を変えるとカーソルのエフェクトも一緒に外れていました。presets/bg/ と presets/cursor/ に分け、config でそれぞれ1行ずつ選ぶ形にしました。

config-file = presets/bg/pool-v2.conf
config-file = presets/cursor/stone.conf
WARNING

custom-shader は書いた順に重なります。 背景の行をカーソルの行より上に書かないと、カーソルのエフェクトが背景に塗りつぶされます。

文字が揺れる波紋#

次に、背景に波紋を作りました。以前使っていた water.glsl は、背景と一緒に文字も揺れるシェーダーです。この揺れ方を取り入れ、波紋が通ったところだけ文字が揺れる形にしています。

water.glsl が文字まで揺らしていたのは、画面を読み取る座標(UV)をずらしていたからです。これまでの背景シェーダーは文字を歪ませないように作っていましたが、rain_ripple.glsl だけは、揺らすことを目的にこの方法を使っています。

float x = dist - radius;
float envelope = exp(-(x * x) / (RING_WIDTH * RING_WIDTH));
float wave = sin(x / WAVELENGTH * TAU) * envelope;

vec2 offset = (d / dist) * wave * DISTORT;
vec4 src = texture(iChannel0, (fragCoord + offset) / iResolution.xy);

輪の前後の帯にだけ正弦波の山と谷を立て、その高さの分だけ、中心から外向きに読み取り位置をずらします。輪が通り過ぎれば、文字は元の位置に戻ります。

背景の方は、カーソルと関係なく波紋を湧かせるので、1つしか出せない制約はありません。画面を 240px のマス目に分け、マスごとに乱数で波紋の出る時刻と位置をずらしています。1ピクセルが見るのは周囲 3x3 マスの波紋だけで、波紋の最大半径をマスの大きさ以下にしておけば取りこぼしはありません。

この波紋を、打鍵した位置から出るように書き換えたのが rain_cursor.glsl です。

カーソルのまわりを揺らさない#

揺れ幅を 3px から 8px に上げると、波紋はカーソルの位置から始まるので、今打っている行の文字がいちばん大きく揺れます。打っている行は読める状態にしておくため、カーソルのまわりは揺らさず、外側ほど大きく揺れるように作り直しました(rain_cursor_big.glsl)。

変えたのは3か所です。

変更中身
揺らさない範囲カーソルを中心に、横 220px・縦 40px の楕円の内側は揺らさない。打っている行と上下1行くらいが入る
揺れ幅広がり始めは 3px、広がりきると 14px
消え方寿命の6割を過ぎてから消え始める

楕円は、座標を横と縦の半径で割ってから長さを取ると、1 になるところが楕円の縁になります。

float e = length((fragCoord - current) / vec2(CALM_X, CALM_Y));
float calm = smoothstep(1.0, 1.0 + CALM_EDGE, e);  // 楕円の内側で 0、外へ向かって 1

消え方を変えたのは、揺れ幅を外側ほど大きくしても、以前の消え方では効かなかったためです。以前は広がるのと同時に弱くなっていて、外側に届くころには揺れがほとんど残っていませんでした。

右下の入力欄の先頭にカーソルがあり、その行の文字はほぼそのまま読める。左と中央の列では、カーソルから広がった波紋が通った部分の文字が輪の形に歪んでいる

右下の入力欄の先頭にカーソルがあります。カーソルのある行の文字は、ほぼそのまま読めます。数行上の文字と、左側の設定ファイルの文字は、波紋が通った部分だけ輪の形に歪んでいます。背景は pool-v2 です。

シェーダーの座標は画面の実ピクセル単位です。Retina ディスプレイでは、横 220px の楕円は見た目で約 110pt の幅になります。

連打で前の波紋が消える#

連打すると、打鍵のたびに波紋が最初からやり直しになります。前の波紋を残すには、過去の打鍵がいつどこであったかが要ります。しかし、手に入るのは1つ前の位置だけで、その位置で打った時刻は分かりません。

1つ前の位置は iPreviousCursor で分かるので、そこにも波紋を描き足しました。時刻が分からない部分は、「今の波紋より 0.3 秒早く始まった」ことにして補っています。

vec3 r = ripple(fragCoord, current, t);
r += ripple(fragCoord, cursorCenter(iPreviousCursor), t + PREV_HEAD_START) * PREV_STRENGTH;

連打すると、新しい小さな波紋と、ひと回り大きい前の波紋が重なって見えます。残せるのは1つ前の分までです。しばらく止まってから打った場合も、1打目で前の位置に 0.3 秒分広がった波紋が出ます。本来はとっくに消えているはずの波紋です。

水切りの石#

カーソルが大きく動いたとき(gg や G などで行を大きくまたいだとき)に、水切りの石が跳ねて、着水点ごとに波紋を残すようにしました(stone_skip.glsl)。

石の軌道は、前の位置、今の位置、動いてからの経過時間の3つで決まります。石が飛ぶのは前の位置から今の位置までの線の上で、どこまで進んだかは経過時間から計算できます。前のフレームを覚えておく必要がないので、連打の波紋とは違って、近似なしで描けます。

跳ねる距離は、1回ごとに 0.6 倍に縮めています。k 回目の着水点が道筋のどこにあるかは、等比数列の和で出ます。

float landingAt(int k) {
    if (k < 0) return 0.0;
    float r = HOP_RATIO;
    return (1.0 - pow(r, float(k + 1))) / (1.0 - pow(r, float(SKIPS)));
}

最後の着水点(k = SKIPS - 1)で値が 1 になり、今のカーソル位置で石が沈みます。

1回の跳躍にかける時間はどれも同じ(0.6 秒 ÷ 5 回 = 0.12 秒)にしています。跳ねる距離だけが縮むので、石は着水のたびに段階的に遅くなります。

跳躍進む距離(全体に対する割合)速さ(1回目に対する割合)
1回目43%100%
2回目26%60%
3回目16%36%
4回目9%22%
5回目6%13%

最初の 0.12 秒で道のりの半分近くまで進み、最後は1回目の1割ちょっとの速さで今のカーソル位置に追いつきます。波紋も着水のたびに 0.75 倍ずつ弱く、小さくしています。

カーソルを大きく動かしたときの3秒間です。白い小さな点が石で、前のカーソル位置から今の位置へ跳ねていきます。石が通った後ろで、着水点のまわりの文字が波紋の形に歪みます。

22行目のカーソルへ向かう途中の白い石と、その上下の行で波紋の形に歪んだ文字

上の動画から、石が22行目のカーソルへ向かっている途中のコマを切り出したものです。石の上下の行で、先に着水した点の波紋が文字を歪ませています。

石そのものは、半径 3px の点で描いています。画面は真上から見ている形なので、跳ねている高さは点の大きさで表しました。跳躍の真ん中でいちばん大きく、着水の瞬間に元の大きさに戻ります。跳ねるたびに高さも低くしています。

float height = sin(u * 3.14159265) * pow(HOP_RATIO, float(hop));
float size = STONE_SIZE * (1.0 + STONE_LIFT * height);

このシェーダーが計算をするのは、4セル以上動いてから約2秒の間だけです。それ以外の時間は、画面をそのまま返して終わります。

カーソルのプリセット cursor/stone は、rain_cursor_big + stone_skip + mode-glow です。光の尾(cursor_blaze)は、石と同じく行をまたぐ移動で出るため、重ならないように外しました。

この日に分かったこと#

やりたかったこと必要な情報結果
打鍵した位置に波紋を出す今の位置、動いた時刻作れた
連打で前の波紋を残す過去の打鍵すべての位置と時刻作れない(1つ前の位置だけ、時刻を仮定して描いた)
大きく動いたとき水切りの石を跳ねさせる前の位置、今の位置、動いた時刻作れた
背景で波紋を湧かせ続ける時刻だけ作れた(複数の波紋も出せる)

カーソルに反応するシェーダーで作れるかどうかは、今の位置・1つ前の位置・最後に動いた時刻の3つから計算できるかで決まります。背景のようにカーソルと関係なく時刻だけで決まるものには、この制約はありません。

現在の構成は bg/pool-v2 + cursor/stone で、git タグ favorite-stone に保存しています。

プールのバックグラウンドに水切りのカーソルが相性よくていいですね!気に入りました!

orangeVaper
この記事は AI(Claude) を活用して書いています
アイキャッチ画像は ComfyUI + Flux.1 で生成しています
Ghosttyのカーソルシェーダーで波紋を描く — iPreviousCursorで作れる水切りの石と、作れない連打の波紋
https://orangevaper.dev/posts/ghostty-cursor-shader-ripple-stone/
作者
orangeVaper
公開日
2026-09-25
ライセンス
CC BY-NC-SA 4.0