傾けた写真のフチがギザギザになったのに気付いて、原因と直し方を追った制作メモです。
詳細画面のアルバムレイアウトでは、写真カードを Transform.rotate でほんの少しだけ傾けている。角度は 0.014(ラジアン、約0.8°)。紙のアルバムに写真を貼ったときの、きっちり水平じゃない感じを出すための演出だ。
この傾き自体は、4か月以上前からずっと入っていたものだ。気付かなかったのは、アルバムレイアウトが写真の入り方によっては出てこなくて、傾いた写真を大きく見る機会がそもそも少なかったから。Windows の実機(シミュレータではなく、実際に PC 上で動かしたアプリ)で触っていて、ようやく写真のフチがガタガタになっているのに気付いた。白いカードの縁が、なめらかな直線じゃなくて、細かい段差でギザギザしている。そのとき自分で口にした疑問がこれだった。
写真を斜めにするとフチがジャギジャギになる。角度が微妙すぎるとこうなっちゃうのか?
半分は当たっていて、半分は逆だった。「角度が中途半端だから」で合っているんだけど、中途半端=浅い角度のほうが、むしろギザギザは目立つ。傾きが大きいほど荒れそう、という直感とは逆の話だった。
綴Photo の実装は AI と進めている。この傾きの演出も、後に出てくる直しも、自分で1行ずつ書いたものではない。実機を触っていて荒れに気付いたのは自分で、そこから原因を AI と詰めて、返ってきた説明を後から辿って腑に落とした——というのが以下の中身だ。
なぜ斜めのフチはギザギザになるのか
画面のピクセルは正方形のマス目に並んでいる。水平・垂直の線ならマス目にぴったり乗るので、フチはきれいな直線になる。でも斜めの線は、マス目の途中を通る。マスは半分だけ光る、みたいなことができない(フィルタをかけなければ)から、斜めの線を近似するには「ここまでは同じ高さ、ここで1段下がる」を繰り返す階段状にするしかない。
Transform.rotate は、filterQuality を指定しないと(初期値は「指定なし」= null)子ウィジェットを回転後の位置で描き直す経路を通る。ドキュメントにも「filterQuality が null なら子を re-render する」と書いてある。中間バッファを挟まないぶん、一番速い経路だ。
ここで引っかかったのは、この経路が「アンチエイリアスを切っている」わけではないこと。Flutter の塗りは既定でアンチエイリアスが有効で(Paint.isAntiAlias は既定 true、写真を囲む ClipRRect も既定が Clip.antiAlias)、理屈の上ではフチはならされているはずだった。それでも実機では段差として見えた、というのが今回の出発点になる。
もうひとつ引っかかったのが、「じゃあ、なんで Transform.rotate のほうには最初から指定していなかったのか」だった。コードを見に行くと、写真そのものにはすでにフィルタがかかっていた。写真を表示する共通ウィジェットは filterQuality の既定値が medium になっていて、アルバムの写真も指定なしのままその既定値を通っている。画質まわりのスイッチは、入れてあるつもりだった。
見落としていたのは、同じ名前のスイッチが2か所にあって、効く対象が違うことだった。写真ウィジェット側の filterQuality は写真の中身、つまり画像を伸び縮みさせるときの補間に効く。Transform.rotate の filterQuality はカードごと回すときの描き方に効く。しかも今回荒れていたのは写真の中身ではなく、写真を載せている白いカードの縁だ。白い縁は画像ではなく Container の塗りなので、写真側の設定をどれだけ上げても届かない。片方が入っている時点で「ここは手当て済み」に見えてしまう形になっていた。
浅い角度ほど、段が横に長く伸びて目立つ
ここが直感と逆だったところ。階段の「1段」がどれくらい横に伸びるかは、角度で決まる。傾きが浅いほど、1ピクセル下がるまでに横へ長く進む。
約0.8°だと、フチが1ピクセル下がるのにおよそ70ピクセルも横に進む(ごく浅い坂を1段ぶん下るのに、それだけ助走がいる、ということ)。つまり、ほぼ真横に伸びた長い辺が、70ピクセルごとにガクッと1段落ちる。まっすぐに見えるはずのフチに、ぽつぽつと長い段差が入るので、かえって「そこだけずれてる」ように目立つ。
逆に45°くらいまで傾けると、1ピクセル進むごとに1ピクセル下がる細かいノコギリ状になる。段差そのものは無数にあるんだけど、1段が小さくて均一だから、全体がザラついた質感として馴染んでしまい、個々の段は目に付きにくい。
最初に口にした「角度が微妙すぎるとこうなるのか」は、感覚としては当たっていた。浅い傾きだと、段が横に伸びて一番目立つゾーンに入る。
filterQuality を渡してレイヤー経由に切り替える
直し方は1行だった。Transform.rotate に filterQuality を渡す。指定すると、子を一度レイヤー(中間バッファ)に描いてから、フィルタ付きで変換する経路に切り替わる。変換のときに境目のピクセルを補間するので、輪郭のならし方が変わる。今回入れたのは FilterQuality.medium。コードは AI に書いてもらい、自分が決めたのは4段階のうちどれを置くかだった(後述)。
// 写真カード本体
Transform.rotate(
angle: 0.014, // ラジアン指定。約0.8°=一番段差が目立つ浅さ
// 浅い傾きは斜めのフチが階段状にジャギる。filterQuality を指定すると
// レイヤーに描いてアンチエイリアス付きで回すので輪郭が滑らかになる。
filterQuality: FilterQuality.medium, // ← ここがアンチエイリアスのスイッチ
child: Container(
// ここが「回される子」。この中身ごと傾く
// 白いカード + 影 + 写真
...
),
)コード中のコメントは当時そのまま入れたもので、正確には「アンチエイリアスのスイッチ」というより「レイヤー経由に切り替えるスイッチ」だ。上に書いたとおり、指定しない側もアンチエイリアス自体は効いている。
FilterQuality は none / low / medium / high の4段階。ただし単純な「上ほど高品質」の梯子ではないらしい。ドキュメントによると medium が全域でバランスの良い選択で、high(バイキュービック)が明確に勝つのは5〜10倍を超える拡大のとき。逆に縮小方向では high が medium より悪くなることもあると書かれている。
コスト面も「段階を上げた分」だけではなくて、none も含めて指定した時点でレイヤー経由に切り替わる固定費がかかり、その上に段階ごとのサンプリング計算が乗る。今回は拡大縮小ではなく約0.8°回すだけなので、素直に推奨どおり medium にした。詳細画面のモバイル・デスクトップ両方の同じ箇所に入れている。
演出の傾き自体は残す
一番手っ取り早い「直し方」は、角度を 0 にして写真をまっすぐ貼ることだった。傾けなければ、フチは画面の縦横に沿うのでギザギザは出ない。
でもそれはやらないと決めた。わずかな傾きは、紙のアルバムっぽさを出すための演出で、この画面の手触りそのものだ。ギザギザが嫌だからといって演出を消すのは、荒れを直すために作りたかったものを捨てることになる。だから傾きは残して、レンダリング側で滑らかにする方向にした。
まだ確かめきれていないところ
そもそも、既定でアンチエイリアスが効いているはずのフチが、なぜ0.8°という浅さだと段に見えたのか。ここは正直まだ説明しきれていない。レンダラ(Impeller か Skia か)やディスプレイのピクセル密度あたりが絡んでいそうだが、確かめていないので断定しない。
直し方の側も同じで、レイヤーに焼いてから補間で回す経路に変えれば輪郭のならし方が変わる、まではドキュメントで裏が取れている。ただ medium の補間が0.8°というかなり浅い角度で「確かに消えた」と言えるところまで滑らかにできているかは、実機で撮って見比べるのがまだ済んでいない。いま手元に出ている写真がたまたま傾けない側のレイアウトに寄っていて、傾いた写真がなかなか画面に出てこない。
もし medium でまだ目立つようなら、high も試してみる。ただし上で書いたとおり high は拡大時に効くものなので、今回みたいなほぼ等倍の回転で効くかは分からない。効かなければ、そもそも Transform.rotate で回すのが正しいのかから考え直すことになりそうだ。原因の方向は掴めたけれど、最終的な見え方の確定は実機待ち、というのが正直なところだ。
この記事は2026年7月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。