WPFで画像にタイル状のマスキングをして表示する

# 目的
WPFにおいて、このように画像を表示したいです。
![1.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/573413/895cdca5-b6ef-a9a5-f718-b8a4277f9d0b.png)
つまり、**画像にタイル状のマスクを設定し、マスクがoffの部分は透明にして表示したい**です。（本記事の内容の応用で一部を「半透明」にすることもできると思いますが、筆者は試していませんので触れません。）
また、**マスクが変化したときにラグが起こらない程度に処理が高速**であってほしいです。

WPFの**ImageコントロールのOpacityMaskにImageBrushを設定**することでこのような表示を達成できましたので以下で述べます。


# 背景と詳細な要件
## 個人的背景
筆者はブラウザ上で動くブロック崩しエンジンBB2021と、ブロック崩しゲームを簡単に生成するためのエディタBB2021_editorを開発中です。（[こちら](http://htymfield.html.xdomain.jp "筆者HP")で開発中のゲームのサンプルをプレイできます。）開発中のエディタの画面を示します。
![2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/573413/e83f7bac-ab6d-4214-c91c-103e9fd97542.png)
立ち絵は[こちら](https://seiga.nicovideo.jp/seiga/im9796439)をお借りしています。
ウィンドウ中央部ではブロックの配置を設定しています。ブロックが配置されているところは前景用画像を、配置されていないところは背景用画像を表示します。前景用画像をマスキングすることで、この表示の切り替えを行っています。

## 詳細な要件
マスクは以下のようにします。下の画像をご覧ください。

- マスクのブロックの形状はは正方形。
- マスクとマスキング対象の画像は縦横比が一致しなくてよい。
- マスクのサイズ、配置は画像に合わせて以下のように設定される。
    - 画像とマスクの上側が一致するように配置される。
    - 画像とマスクの横幅が一致するようにマスクのサイズが変更される。このときマスクの縦横比は変わらない。
- マスクの画像からはみ出た部分は画像に影響しない。
- マスクが存在しない部分は画像を表示しない。
![3.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/573413/b6461f86-5dad-2c9f-1ae8-a0a87279412c.png)


# サンプル
このようなサンプルプログラムを作りました。以下このプログラムについて紹介します。ソースコードは最下部に置いておきます。

![4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/573413/a6f29f30-1190-c472-67d2-fb8558877dc4.png)


- 左：画像をマスキングした結果です。
- 中央：ランダムに生成したマスクです。
- 右：マスクの行数、列数を編集するスライダーとマスクのランダム生成を開始するボタンです。ボタンを押すとマスク、画像の表示が変化します。

マスキング処理を行っている部分のxamlを示します。

```xml:MainWindow.xaml
<Image Source="./Resources/Sample.PNG" RenderOptions.BitmapScalingMode="NearestNeighbor">
    <Image.OpacityMask>
        <ImageBrush ImageSource="{Binding MaskImage}"
            TileMode="None"
            AlignmentY="Top"
            Stretch="{Binding StretchMode}"/>
    </Image.OpacityMask>
</Image>
```

マスキング対象の画像はImageコントロールのソースに設定します。ImageコントロールのOpacityMaskにマスク画像によるImageBrushを登録することでマスキングを行います。マスク画像はWrittableBitmapクラスで生成します。

マスク画像のサイズが1000*1000であってもラグを感じることなく高速に動きました。タイル状にマスキングをするならばいい方法なのではないかと思います。

## ポイント
### RenderOptions.BitmapScalingMode="NearestNeighbor"
表示における補間オプションを最近傍補間に設定します。これによりマスクがタイル状になります。設定しなかった場合、マスクは滑らかに変化するものとなります。

### Stretch（マスク画像の拡大縮小方法オプション）
対象画像のサイズとマスク画像のサイズが異なるため、マスク画像を対象画像に合わせてサイズ変更します。今回の要件を満たすためには、マスク画像の行数列数に合わせてStretchを変更する必要があります。

```cs:MainWindow.xaml.cs
// NumRow, NumColumn: マスク画像の行数列数（ピクセル数）
// imageHeight, imageWidth: 対象画像の行数列数（ピクセル数）
StretchMode = (double)NumRow / NumColumn > imageHeight / imageWidth ?
                    Stretch.UniformToFill : Stretch.Uniform;
```

### マスク画像
OpacityMaskにImageBrushを設定する場合、マスク画像はアルファチャンネルの値のみが使用されます。

## 試行錯誤
最初はGeometryGroupを使用する方法を試していました。マスクが動的に変わるとGeometryGroupの管理がめんどくさいのと、速度がよろしくなさそうだったので止めました。

## 参考
[不透明度マスクの概要](https://docs.microsoft.com/ja-jp/dotnet/desktop/wpf/graphics-multimedia/opacity-masks-overview?view=netframeworkdesktop-4.8)
[無限の透明市松模様をWriteableBitmapとImageBrushのタイル表示で作成](https://gogowaten.hatenablog.com/entry/15917385)

Geometryを使うなら
[切り抜きClip、GeometryGroupとCombinedGeometry](https://gogowaten.hatenablog.com/entry/15921152)



## ソースコード

```xml:MainWindow.xaml
<Window x:Class="MainProject.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:MainProject"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800" Background="AliceBlue" d:DataContext="{d:DesignInstance local:MainWindowVM}">

    <Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinition/>
            <ColumnDefinition/>
            <ColumnDefinition Width="100px"/>
        </Grid.ColumnDefinitions>

        <!--Column0-->
        <Border BorderBrush="Red" BorderThickness="2" HorizontalAlignment="Center" VerticalAlignment="Center" >
            <!--マスキング処理を行う対象の画像-->
            <Image Source="./Resources/Sample.PNG" RenderOptions.BitmapScalingMode="NearestNeighbor">
                <Image.OpacityMask>
                    <!--マスク画像を用いたImageBrush-->
                    <ImageBrush ImageSource="{Binding MaskImage}"
                            TileMode="None"
                            AlignmentY="Top"
                            Stretch="{Binding StretchMode}"/>
                </Image.OpacityMask>
            </Image>
        </Border>

        <!--Column1-->
        <Border BorderBrush="Red" BorderThickness="2" Grid.Column="1" HorizontalAlignment="Center" VerticalAlignment="Center" >
            <!--マスク画像（参考用）-->
            <Image Source="{Binding MaskImage}" RenderOptions.BitmapScalingMode="NearestNeighbor" Grid.Column="1"/>
        </Border>

        <!--Column2-->
        <StackPanel Grid.Column="2">
            <!--マスク画像のピクセル数-->
            <TextBlock Text="{Binding NumRow, StringFormat=行数: {0}}"/>
            <Slider Value="{Binding NumRow}" SmallChange="1" Minimum="1" Maximum="1000"/>

            <TextBlock Text="{Binding NumColumn, StringFormat=列数: {0}}"/>
            <Slider Value="{Binding NumColumn}" SmallChange="1" Minimum="1" Maximum="1000"/>

            <Button Content="ランダムにマスキング" Command="{Binding SetRandomMask}"/>

        </StackPanel>
    </Grid>
</Window>
```

```cs:MainWindow.xaml.cs
using System;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;

namespace MainProject {
    public partial class MainWindow : Window {
        public MainWindow() {
            this.DataContext = new MainWindowVM();
            InitializeComponent();
        }
    }

    // MainWindowのViewModel(兼Model)
    public class MainWindowVM : INotifyPropertyChanged {
        // マスク画像
        private WriteableBitmap _maskImage = new WriteableBitmap(1, 1, dpi, dpi, format, null);
        public WriteableBitmap MaskImage {
            get => _maskImage;
            set {
                SetProperty(ref _maskImage, value);
                // マスク画像の縦横比、対象画像の縦横比に応じてマスクの拡大モードを変える必要がある。
                StretchMode = (double)NumRow / NumColumn > imageHeight / imageWidth ?
                    Stretch.UniformToFill : Stretch.Uniform;
            }
        }

        //サンプル画像のサイズ（ピクセル数）
        //整数値だが、計算上doubleの方が楽。
        private static readonly double imageWidth = 541;
        private static readonly double imageHeight = 892;


        // マスク画像の拡大モード
        private Stretch _stretchMode = Stretch.Uniform;
        public Stretch StretchMode {
            get => _stretchMode;
            set => SetProperty(ref _stretchMode, value);
        }

        // マスク画像の行数(縦のピクセル数)
        private int _numRow = 1;
        public int NumRow {
            get => _numRow;
            set => SetProperty(ref _numRow, value);
        }

        // マスク画像の列数(横のピクセル数)
        private int _numColumn = 1;
        public int NumColumn {
            get => _numColumn;
            set => SetProperty(ref _numColumn, value);
        }

        // ランダムなマスク画像を設定するコマンド
        private ICommand _setRandomMask;
        public ICommand SetRandomMask {
            get {
                _setRandomMask ??= new DelegeteCommand() {
                    ExecuteHandler = (_) => {
                        var byteArray = GenerateRandomByte();
                        var newMask = Byte2Bmp(byteArray, NumColumn, NumRow, format);
                        MaskImage = newMask;
                    },
                    CanExecuteHandler = (_) => { return true; }
                };
                return _setRandomMask;
            }
        }

        // マスク画像の元となるランダムなバイト配列を作成する関数
        private byte[] GenerateRandomByte() {
            var ret = new byte[NumRow * NumColumn * numColor];
            var random = new Random();
            for (int i = 0; i < NumRow * NumColumn; i++) {
                // OpacityMaskにはアルファチャンネルの値のみが使用される。
                // 各ピクセルのアルファチャンネルの値をランダムに0(可視)か255(不可視)に設定。
                ret[i * numColor + 3] = (byte)(random.NextDouble() > 0.5 ?
                    0 : 255);
            }
            return ret;
        }


        private static readonly PixelFormat format = PixelFormats.Bgra32;
        private static readonly int numColor = 4;
        private static readonly int dpi = 96;
        // バイト配列からWriteableBitmapを生成する。
        public static WriteableBitmap Byte2Bmp(byte[] array, int w, int h, PixelFormat format) {
            var bmp = new WriteableBitmap(w, h, dpi, dpi, format, null);
            var stride = bmp.BackBufferStride;
            bmp.WritePixels(new Int32Rect(0, 0, w, h), array, stride, 0);
            return bmp;
        }



        // 以下は主題と関係ありません。

        // バインディングをしやすくするためのメソッド
        public event PropertyChangedEventHandler PropertyChanged;
        // ref: http://blog.okazuki.jp/entry/2014/12/23/180413/
        protected virtual bool SetProperty<T>(ref T field, T value, [CallerMemberName] string propertyName = null) {
            if (Equals(field, value)) { return false; }
            field = value;
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
            return true;
        }
    }

    // コマンドを扱いやすくするためのクラス
    public class DelegeteCommand : ICommand {
        // ref: https://atmarkit.itmedia.co.jp/ait/articles/1011/09/news102_3.html
        #region ICommand
        public event EventHandler CanExecuteChanged;
        public bool CanExecute(object parameter) {
            var d = CanExecuteHandler;
            return d == null || d(parameter);
        }
        public void Execute(object parameter) {
            ExecuteHandler?.Invoke(parameter);
        }
        #endregion

        public Action<object> ExecuteHandler { get; set; }
        public Func<object, bool> CanExecuteHandler { get; set; }
        public void RaiseCanExecuteChanged() {
            CanExecuteChanged?.Invoke(this, null);
        }
    }
}
```