テキスト装飾で良くあるドロップシャドウを作ってみよう、と思う。

仕様の検討

ドロップシャドウは仕様が色々考えられる所で、どの用途でもいい感じ、というのは難しい気がする。 最初の実装は一番シンプルな所から始めたいので、シナリオをテキストに制限しよう。

一つ上のレイヤーのテキストをドロップシャドウして現在のレイヤーに描く、という仕様にする。 下のレイヤーに描かれるのは場合によってはややこしさもあるけれど(普通は一番上のレイヤーに書くので)、 まずはわかりやすさを重視してこの仕組みで。

images/DropShadow/2025_1210_114721.png

単に移動ぼかしっぽく描いてみたが、これでは文字に沿った感じにならないな。 はみ出す方は文字の真下が一番暗くなって、はみ出さない方は全くはみ出さない。 まぁこの辺はアルゴリズムを考える時に考えていこう。

とりあえず上のレイヤーの不透明の影が現在のレイヤーに出来る、という仕様で。

テストデータの用意

2025年12月10日現時点のMFG実装ではでは、32bppと64bppのレイヤー以外は対応していません。 今回のようなフィルタが増えてきたら参照だけならテキストレイヤーも対応したい所ですが、まずは画像のレイヤーに変換しましょう。

FASE3ではテキストのレイヤーを右クリックして、「テキストレイヤーを画像レイヤーに変換」をすれば作る事が出来ます。 レイヤーを二つ持ったmdzファイルを作り、これをMFGStudioのpreview_targetsに置く事にします(MFGStudioのv1.0.09でユーザーが独自のmdzやpngなどをターゲットに追加できるようになりました、詳細はリリースノートを参照ください)。

images/DropShadow/2025_1210_123802.png

最初のバージョンを考える

最初に実装をするために、どういうものがあればいいのかのアイデアを適当に書き綴ってみる。

物理的な影で考えてみる

まずは物理的に影が落ちている、という前提で考えてみよう。

光源は高さと位置を指定して、テキストのすぐそばは黒くなっていて、そこから離れるほどに薄くなる、という感じだろうか?

描画の点から考えて、光源が見えるか、と考える方がいい気がするな。 テキストにある高さがあるとして、光源が見えるかどうか。本当は端の回折でぼかしを考える必要があるけれど、 まずは0-1で考えてみよう。

横から見るとぉういう感じか。

images/DropShadow/2025_1210_115537.png

ただ、これだと青の点と光源の間の全てのピクセルをいみないと描けない。 だから逆写像を求める必要はありそう。

images/DropShadow/2025_1210_120339.png

赤の斜線の範囲を求めるには、高さの分for文を回す必要はありそうか。これはCPU的になってしまっているのでGPUでどう解くかは考える必要がありそうだな。

と書いていて思ったが、光源は位置と高さというパラメータでは反対側にも影が落ちてしまうな。普通は無限遠という前提なので平行にすべきか。 むしろパラメータとしては高さではなく方向ベクトルなんだな。360度と90度の二つのパラメータで向きは決まるか?

全体としてはテキストの高さ、光源の向きの二つで決まりそうか?一つになるかもしれないけれど、この辺は結論が出てから整理していけばいいだろう。

ここまで考えて、どうも物理的な影として考えるのは、やりたい事の単純さの割に無駄に複雑な気もするな。

特定の方向にピクセルがあるかで考える

先のイラストの赤の斜線の側から考えて、単に特定の方向に何pxか見ていって、そこにピクセルがあったら影、とするのはどうだろう? 縁のぼかしとかはまずは考えずに白黒でいく。 方向と探索範囲の二つのパラメータで影を描いてみよう。

images/DropShadow/2025_1210_124307.png

この路線がいい気がするな。 とりあえず最初はこの路線で実装してみるか。

単に方向の範囲にピクセルがあるかで白黒を描くバージョン

一番簡単な実装という事で、一つ上のレイヤーを特定の方向に向かって1pxずつ見ていって不透明なピクセルがあったら黒を打ってみて、どうなるかを見てみましょう。 フィルタを開発するには、実際に動かしてみてから考えるのも大切です。

45度の方向に10ピクセル確認するコードを書いてみる

まずはさらに単純に、45度の方向に10px見てみましょう。 斜めの扱いとかに問題はあるでしょうけれど、まずは一番単純なものを実装してみる。

現在の位置が不透明でも黒は打つ、一つ隣は1, 1だけ斜めに移動してしまいましょう。

一つ上のレイヤーはMFGでは、input_u8[1] で参照出来ます。詳細はテンソル#他のレイヤーの参照をどうぞ。

サンプラーも指定すると以下のように出来ます。

let upper = sampler<input_u8[1]>(address=.ClampToEdge)

10pxチェック、はreduceを使い、黒なら1、それ以外なら0を返す感じにしてしまいます。 何も考えずに書くと、以下のようなコードになりました。


@title "45度で10pxチェック"

let upper = sampler<input_u8[1]>(address=.ClampToEdge)

def result_u8 |x, y| {
  let bin = reduce(init=0, 0..<10) | index, accm | {
    ifel(accm != 0, accm, ...)
    elif(upper(x+index, y+index).w != 0, 1, ...)
    else(0)
  }
  u8[0, 0, 0, 255*bin]
}

短いですね。この辺の簡潔さはさすがMFG。

結果は以下になりました。

images/DropShadow/2025_1210_125802.png

一応正しくは動いていそうに見えます。

下のレイヤーは以下です。

images/DropShadow/2025_1210_125835.png

これだけだと良くわからないですね。 少し右下に向かって広がっているはずです。

方向と範囲をパラメータ化してみる。

最初のバージョンとしては悪くなさそうなので、45度と10px決めうちの部分をパラメータ化します。

ピクセルはcosとsinの大きい方が少なくとも1pxはズレるように見ていきたいので、 45度の時のルート2分の1の時に1pxとなる差分なら良さそうです。cosとsinに1.415くらいを掛ければ良いでしょうか。

@title "ドロップシャドウ(シンプル)"

@param_i32 range(SLIDER, label="幅", min=5, max=30, init=15)
@param_f32 angle(DIRECTION, init=0.0)


let upper = sampler<input_u8[1]>(address=.ClampToEdge)

def result_u8 |x, y| {
  let dir = [cos(angle), sin(angle)]*1.415

  let bin = reduce(init=0, 0..<range) | index, accm | {
    let [xi, yi] = [x, y] + i32(dir*index)
    ifel(accm != 0, accm, ...)
    elif(upper(xi, yi).w != 0, 1, ...)
    else(0)
  }
  u8[0, 0, 0, 255*bin]
}

こんな感じになりました。

images/DropShadow/2025_1210_153652.png

ちょっと面白いですね。

雑感

こんなに単純だときっとダメだろうな、と思ってやってみましたが、意外とちゃんと動いているな、という印象です。 こういうインタラクティブなものは触っていて面白いですね。

普通はbloomフィルタのように、低解像度にしてガウスぼかししたものをターゲットにするのかな、 という気もします。 こうしないとたまたま斜めの具合でへんに影が出来たり出来なかったりしたり、入り組んだところの振る舞いなどが影っぽくないかな、 と思ったからです。

ただ作ったものを触った印象としては、これでも十分ハードなエッジとしては機能しているな、と思いました。 こういうのは常識で考えて複雑にしてしまうよりも、まず単純なものを試してみるのが大切ですね。

影っぽさを増すためにソフトなエッジにする変更はしていきたい所です。 ただここまでで区切りが良いので、 続きは次のポストに分けたいと思います。