2011年8月17日水曜日

wpf : WriteableBitmapをピクセルスナップさせて等倍表示

後日、追加情報を書きました。

-----------------------------------------------------

自分の環境でちょこっと試しただけなので汎用性については全く調べてないんですが、抱えてても仕方がないのでとりあえず投稿します。

wpfは座標が論理ピクセル単位です。 画像やシェイプをキリのいい座標に置いたつもりでも、実行環境のdpiしだいではズレて配置 → アンチエイリアスされにじんだ表示になってしまいます。 普通のアプリケーションならそれでも良いんでしょうけど、画像処理のソフトなど、デバイスピクセルにスナップさせないと使い物にならないものもあります。 そんなソフトを作るときのために、画像(System.Windows.Controls.Image)をピクセルスナップさせて表示する方法を探してみました。

手順はこんな感じです。

  1. Imageコントロールの左上のレンダリング座標をデバイスピクセルにスナップさせる。
  2. Imageコントロールのサイズをデバイスピクセルにあわせて調整する。

SnapsToDevicePixelsが効かないようなのでこういう手順になります。

レンダリング座標の調整に関する元ネタはこちら。

韓国語ですね。 何が書いてあるのか全く分かりません。 普段はgoogleの検索結果に並んでも絶対にクリックしなかったでしょう。 でも今回だけなぜかクリック → 有用な情報入手です。 こういうこともあるんですね。 もう無いでしょうけど。

言葉が読めないのでソースだけ持ってきて、「コレいる?」ってところを削りました。 あと、変数が使いまわされていて説明に使いづらいところがあったので変更。 その他にもチマチマと変更したらこんなコードになりました。 Imageクラスを改造して座標の微調整をする機能をつけた物です。

// PixelSnappedImage.cs
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;

namespace LocalUtility
{
    class PixelSnappedImage : Image
    {
        private Point _adjust;

        public PixelSnappedImage()
        {
            _adjust = new Point(0.0, 0.0);
            EnableAdjustment = true;
            LayoutUpdated += new EventHandler(OnLayoutUpdated);
        }

        public bool EnableAdjustment { get; set; }

        protected virtual void OnLayoutUpdated(object sender, EventArgs e)
        {
            Point adjustTmp = CalcAdjust();
            if (_adjust != adjustTmp)
            {
                _adjust = adjustTmp;
                InvalidateVisual();
            }
        }

        protected override void OnRender(System.Windows.Media.DrawingContext dc)
        {
            if (EnableAdjustment)
                dc.PushTransform(new TranslateTransform(_adjust.X, _adjust.Y));

            base.OnRender(dc);
        }

        private Point CalcAdjust()
        {
            PresentationSource source = PresentationSource.FromVisual(this);
            if (source == null)
                return new Point(0.0, 0.0);

            Visual root = source.RootVisual;
            CompositionTarget ct = source.CompositionTarget;
            Point controlPos, clientPos, devicePos;

            controlPos = new Point(0.0, 0.0);
            clientPos = this.TransformToAncestor(root).Transform(controlPos);
            devicePos = ct.TransformToDevice.Transform(clientPos);

            devicePos.X = Math.Floor(devicePos.X);
            devicePos.Y = Math.Floor(devicePos.Y);

            clientPos = ct.TransformFromDevice.Transform(devicePos);
            controlPos = root.TransformToDescendant(this).Transform(clientPos);
            return controlPos;
        }
    }
}

変えたところが重要なノウハウだったらどうしましょ? まぁ、その場合は元ネタのコードも試して、それでもダメならマイクロソフトの呪いってことであきらめますか。

まずはCalcAdjustメソッドから説明。 Imageコントロールから見た座標controlPosに左上を意味する(0,0)を入れておきます。 これをRootVisual(たいていの場合Window)上の座標(clientPos) → デバイス上の座標(devicePos)と変換していきます。 デバイス上の座標値に小数点以下の値があったばあい、ズレているのでレンダリングするときににじんでしまいます。 このズレがどの程度あるかを調べるのがCalcAdjustメソッドの役割です。

デバイス座標上でのズレを切り捨てると、Imageコントロールの左上の座標がピクセルにピッタリとあったときの値に調整されます。 逆順にデバイス座標 → RootVisual上の座標 → Imageコントロールから見た座標と変換すると、最初の(0,0)から見てちょうど切り捨てた分だけ左上にズレた論理ピクセル座標が得られるのです。 これをレンダリングするときの調整値として使います。

レンダリングメソッドOnRenderは見ての通り。 DrawingContextに調整値だけ積んで継承元に丸投げです。 注意点は、レンダリング位置は調整しているけどコントロールの論理的な座標はそのままってところですかね?

調整値の計算をするのはLayoutUpdatedイベント中です。 LayoutUpdatedイベントはコントロールのレイアウト変更やサイズ変更、Windowの最大化やコントロールのスクロールなどレンダリングに関するパラメータが変更されたときに発生します。

調整値が変更されたらInvalidateVisualを呼んで再描画しなければなりません。 私は最初「調整値さえあればInvalidateVisualしなくても良いんじゃないの?」と思ったんですが、そうではないようです。 wpfは何をどうバッファリングしているのやら...

とにかく、この方法でピクセルスナップするとスクロールなどのイベントごとに再描画されることになります。 普通のアプリケーションよりは重くなりそうです。 でもこれは、グラフィックボードが吸収してくれるのかな? ノートやタブレットなどのグラフィックボードが非力なマシンには優しくない手法なのかも。

このPixelSnappedImageクラスをxamlで使うにはこう書きます。

<Window x:Class="PixelSnappedImageTest.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:utl="clr-namespace:LocalUtility"
        SourceInitialized="OnSourceInitialized"
        LayoutUpdated="OnLayoutUpdated"
        Title="Test" Height="200" Width="200">
    <ScrollViewer
        VerticalScrollBarVisibility="Visible"
        HorizontalScrollBarVisibility="Visible">
        <utl:PixelSnappedImage
            x:Name="mainImage"
            Stretch="None"/>
    </ScrollViewer>
</Window>

Imageの代わりにネームスペース:PixelSnappedImageを、Nameの代わりにx:Nameを書くだけですね。

ネームスペースは適当です。 このコードをコピー&ペーストで使うときはプロジェクトに合わせて書き換えてください。

MainWindow.xaml.csのコードはこちら。

// MainWindow.xaml.cs
using System;
using System.Windows;
using System.Windows.Interop;
using System.Windows.Media.Imaging;
using System.Windows.Media;

namespace PixelSnappedImageTest
{
    public partial class MainWindow : Window
    {
        private WriteableBitmap _bmp;

        public MainWindow()
        {
            InitializeComponent();
        }

        private void OnSourceInitialized(object sender, EventArgs e)
        {
            // hwndが取得できるのはSourceInitializedイベント以降

            HwndSource source = (HwndSource)HwndSource.FromVisual(this);
            System.Drawing.Graphics g = System.Drawing.Graphics.FromHwnd(source.Handle);
            _bmp = new WriteableBitmap(512, 512, g.DpiX, g.DpiY, PixelFormats.Pbgra32, null);
            //mainImage.EnableAdjustment = false;
            DrawTestImageToBmp();
            mainImage.Source = _bmp;
        }

        // debug表示用
        private void OnLayoutUpdated(object sender, EventArgs e)
        {
            PresentationSource source = PresentationSource.FromVisual(this);
            if (source == null)
                return;

            System.Windows.Media.Visual root = source.RootVisual;
            System.Windows.Media.CompositionTarget ct = source.CompositionTarget;

            Point topLeft = new Point(0.0, 0.0);
            Point clientPos = mainImage.TransformToAncestor(root).Transform(topLeft);
            Point devicePos = ct.TransformToDevice.Transform(clientPos);

            this.Title = "(" + devicePos.X + ", " + devicePos.Y + ")";
        }

        // 目視で確認するための格子を描く
        private void DrawTestImageToBmp()
        {
            int width = _bmp.PixelWidth;
            int height = _bmp.PixelHeight;

            // 格子の幅は中途半端な値に
            int step = 11;

            _bmp.Lock();

            unsafe
            {
                uint* p = (uint*)_bmp.BackBuffer;
                int stride = _bmp.BackBufferStride / 4;

                for (int x = 0; x < width; x += step)
                    for (int y = 0; y < height; y++)
                        p[stride * y + x] = 0xff000000;
                for (int y = 0; y < height; y += step)
                    for (int x = 0; x < width; x++)
                        p[stride * y + x] = 0xff000000;
            }

            _bmp.AddDirtyRect(new Int32Rect(0, 0, width, height));
            _bmp.Unlock();
        }
    }
}

環境のdpiを得るためにSystem.Drawing.Graphicsを使っています。 System.Drawingの参照が必要です。 コメントにあるとおり、hwndが取得できるのはSourceInitializedイベント以降です。 このサンプルではWriteableBitmapをSourceInitializedイベント中に作成しています。

WriteableBitmapの場合、ImageコントロールをStretch="None"にしてdpiを環境に合わせれば等倍表示できます。 拡大縮小の割合さえ正しければWriteableBitmap以外のBitmapSourceでも等倍表示はできるかと思います。 (試してないけど。) その場合は、環境のdpiと画像のdpi両方を考慮しなくちゃいけないのかな?

MainWindow.OnLayoutUpdatedメソッドでウィンドウのタイトルに表示しているのは、PixelSnappedImageのデバイス座標です。 ピクセルスナップが効いていないときのアンチエイリアスの具合を数値で確認するために書いてみました。 mainImage.EnableAdjustment = falseのコメントをはずして実行すると、デバイス座標の小数点以下が大きければアンチエイリアスが目立ち、整数に近ければアンチエイリアスが目立たないのが確認できます。 デフォルトのmainImage.EnableAdjustment = trueのときはその分のズレが調整されているのです。

DrawTestImageToBmpメソッドはアンチエイリアスを目視で確認するための格子模様を書いています。 格子の幅を16とかキリのいい数字にすると変にハマってアンチエイリアスされないこともあるかと考え、ワザと中途半端な値にしました。 意味あったかな?

ポインタを使っているのでプロジェクトのプロパティ/ビルドでアンセーフコードの許可が必要になります。

説明は、こんなもんかな? もっとガリガリ深いコードを書かなければならないかと思っていたけど、案外アッサリとできましたね。 PixelSnappedImage.csさえコピー&ペーストすれば普通のアプリケーションとのコードの違いもそんなにありません。 結局、重要なのは、

  • TransformToDeviceとTransformFromDeviceの存在を知っているか?
  • InvalidateVisualを適切に。

の2点だけだったようです。