【SwiftUI】BlendMode で View を合成する

## はじめに

この記事では、Adobe Photoshop などのデザインツールでおなじみのブレンドモードについて扱います。
コードと実行結果は SwiftUI のものですが、ブレンドモードは Core Graphics が提供しているので、SwiftUI を使わない開発でも知識としては使えると思います。

さて、本題です。
SwiftUI で２つのビューをブレンドしてみます。こんな感じのイメージです。
![はじめに.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63842/ee2f9fb5-ea90-e216-8393-848da1423f11.jpeg)
緑が透過なビューの場合は単純に上に載せるだけなので、非透過な緑色のビューとの合成を考えます。
この場合のサンプルコードはこちら。

```swift
struct ContentView: View {
    var body: some View {
        ZStack {
            Image("lenna")
                .resizable()
                .scaledToFit()
            Rectangle()
                .fill(Color.green)
                .blendMode(.multiply) // ← ココ！
        }
        .edgesIgnoringSafeArea(.all)
    }
}
```

**簡単な説明**

- `ZStack` で `Image` の上に `Rectangle` を載せる
    - `Image` を配置
        - アスペクト比を維持してビューにフィット
    - `Rectangle` を配置
        - `green` で塗りつぶし
        - **乗算ブレンド**
- セーフエリアを無視して配置

**実行結果**

<img width="1048" alt="はじめに２.jpeg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63842/681c163e-2722-93fc-3c50-e860223e5b48.jpeg">

`BlendMode` を指定して配置するだけなので、とても簡単ですが、次のことが気になりました。

- **乗算ブレンド** `.multiply` とはどのような合成方法なのか？
- 他の `BlendMode` を指定した場合はどのような結果が得られるのか？

ということで、本記事では各種ブレンドモードについての情報と実際の表示結果を載せました。
表示結果は SwiftUI のプレビュー機能を使用しています。

**環境**

- Xcode Version 11.5 (11E608c)

## `BlendMode`

SwiftUI の API リファレンスには概要が記載されていなかったので、Core Graphics の API リファレンスも参照しました。次のように記載されていました。
 
> You can find more information on blend modes, including examples of images produced using them, and many mathematical descriptions of the modes, in PDF Reference, Fourth Edition, Version 1.5, Adobe Systems, Inc. If you are a former QuickDraw developer, it may be helpful for you to think of blend modes as an alternative to transfer modes[^1] 


Adobe Systems の _PDF Reference, Fourth Edition_ に正確な情報が記載されているとのことです。
この資料には、全てではないですが、各ブレンドモードの数式も記載がありました。

**PDF Reference に記載のあるブレンドモード**

| [BlendMode](https://developer.apple.com/documentation/swiftui/blendmode) | [CGBlendMode](https://developer.apple.com/documentation/coregraphics/cgblendmode?language=objc) | 名称 |
| ------- | ------- | ------- |
| normal | kCGBlendModeNormal | 通常 |
| multiply | kCGBlendModeMultiply | 乗算 |
| screen | kCGBlendModeScreen | スクリーン |
| overlay | kCGBlendModeOverlay | オーバーレイ |
| darken | kCGBlendModeDarken | 比較（暗） |
| lighten | kCGBlendModeLighten | 比較（明） |
| colorDodge | kCGBlendModeColorDodge | 覆い焼きカラー |
| colorBurn | kCGBlendModeColorBurn | 焼き込みカラー |
| softLight | kCGBlendModeSoftLight | ソフトライト |
| hardLight | kCGBlendModeHardLight | ハードライト |
| difference | kCGBlendModeDifference | 差の絶対値 |
| exclusion | kCGBlendModeExclusion | 除外 |
| hue | kCGBlendModeHue | 色相 |
| saturation | kCGBlendModeSaturation | 彩度 |
| color | kCGBlendModeColor | カラー |
| luminosity | kCGBlendModeLuminosity | 輝度 |

以下、数学的な説明が必要な場合は、_PDF Reference, Fourth Edition_ で記載されていた次の変数を使います。

| 変数 | 意味 |
| ------- | ------- |
| $C_b$ | 背景の色（基本色） |
| $C_s$ | 前面の色（合成色） |
| $C(C_b, C_s)$ | ブレンド関数 |

上記変数は各色成分をもつベクトルです。また、今回扱う関数は、各色成分ごとに分離可能な関数です。

### normal

> **通常**
> 
> 各ピクセルを編集またはペイントして結果色を作成します。これは、初期設定のモードです（通常モードは、モノクロ 2 階調画像やインデックスカラー画像で作業するときには、2 階調化と呼ばれます）。[^2]

ブレンド関数は次の通りです。前面をそのまま反映します。

```math
B(c_b,c_s) = c_s
```
<img width="616" alt="Screen Shot 2020-07-12 at 18.43.59.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63842/fcc6c597-5f1b-0b32-b2d8-6ab4e0ad4d8c.png">
<img width="494" alt="Screen Shot 2020-07-12 at 19.47.38.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63842/531ac9e9-5c39-531d-a8de-678544e68596.png">

### multiply

> **乗算**
> 
> 各チャンネル内のカラー情報に基づき、基本色と合成色を乗算します。結果色は暗いカラーになります。どのカラーも、ブラックで乗算すると結果はブラックになります。どのカラーも、ホワイトで乗算した場合は変更されません。ブラックまたはホワイト以外のカラーでペイントしている場合、ペイントツールで繰り返しストロークを描くとカラーは徐々に暗くなります。この効果は、複数のマーカーペンで描画したような効果が得られます。[^3]

ブレンド関数は次の通りです。背景カラーと前面カラーの RGB の構成要素をそれぞれ乗算します。

```math
B(c_b,c_s) = c_b × c_s
```

背景ビューと前面ビューを入替えても同じ結果を得ることができます。

色の各成分は、0 以上 1 以下の小数です。特に ブラック(0, 0, 0), ホワイト(1, 1, 1) なので、
ブラックで乗算すると結果はブラック、ホワイトで乗算した場合は変更されないことがわかります。

また、0 以上 1 未満の数は乗算を繰り返すほど、0 に近づくので、
multiply ブレンドモードを繰り返すほど徐々に暗くなります。
<img width="613" alt="Screen Shot 2020-07-12 at 18.44.21.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63842/b20e02ff-09ec-a074-b4b6-271eb2a0c66d.png">
<img width="493" alt="Screen Shot 2020-07-12 at 19.47.52.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63842/cd345d29-c2cb-558e-2e8c-be080ce4628d.png">

### screen

> **スクリーン**
> 
> 各チャンネル内のカラー情報に基づき、合成色と基本色を反転したカラーを乗算します。結果色は明るいカラーになります。ブラックでスクリーニングすると、カラーは変更されません。ホワイトでスクリーニングすると、ホワイトになります。この効果は、複数の写真スライドを重ね合わせて投影したような効果が得られます。[^4]

ブレンド関数は次の通りです。

```math
B(c_b,c_s) = 1 – (1–c_b)×(1–c_s)
```

背景カラーと前面カラーをそれぞれ反転させたものを乗算して、その結果の値を反転させます。

この式から、multiply と逆の性質を得ることができます。

- ブラックとスクリーンをすると変更されない
- ホワイトとスクリーンをすると結果はホワイト
- 繰り返すほど徐々に明るくなる

<img width="614" alt="Screen Shot 2020-07-12 at 18.44.40.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63842/843d81ed-02f6-29cd-656c-f5f8be269787.png">
<img width="493" alt="Screen Shot 2020-07-12 at 19.48.02.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63842/b020cd21-ed4f-d212-dae0-7613dd60ed3e.png">

### overlay

> **オーバーレイ**
> 
> 基本色に応じて、カラーを乗算またはスクリーンします。基本色のハイライトおよびシャドウを保持しながら、パターンまたはカラーを既存のピクセルに重ねます。基本色は、置き換えられませんが、合成色と混合されて基本色の明るさまたは暗さを反映します。[^5]

<img width="615" alt="Screen Shot 2020-07-12 at 18.44.59.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63842/276f1adb-f6eb-2c43-1236-b61c44d6fa4f.png">
<img width="495" alt="Screen Shot 2020-07-12 at 19.48.20.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63842/70036cf5-5e38-a2fc-c7b5-3822d4f6a169.png">


### darken

> **比較（暗）**
> 
> 各チャンネル内のカラー情報に基づき、基本色または合成色のいずれか暗い方を結果色として選択します。合成色よりも明るいピクセルが置き換えられ、合成色よりも暗いピクセルは変更されません。[^6]

ブレンド関数は次の通りです。

```math
B(c_b, c_s) = min(c_b, c_s)
```
<img width="614" alt="Screen Shot 2020-07-12 at 18.45.19.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63842/e7a7bc2c-c19e-a565-0b52-933489be0e20.png">
<img width="495" alt="Screen Shot 2020-07-12 at 19.48.58.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63842/4db6bf9b-cc60-664b-fe70-2faa2c0a1b7f.png">


### lighten

> **比較（明）**
> 
> 各チャンネル内のカラー情報に基づき、基本色または合成色のいずれか明るい方を結果色として選択します。合成色よりも暗いピクセルが置き換えられ、合成色よりも明るいピクセルは変更されません。[^7]

ブレンド関数は次の通りです。

```math
B(c_b, c_s) = max(c_b, c_s)
```
<img width="615" alt="Screen Shot 2020-07-12 at 18.45.34.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63842/ee4bb700-60e3-e0c8-89b1-8b400e87f66e.png">
<img width="496" alt="Screen Shot 2020-07-12 at 19.49.10.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63842/1785cec5-1e03-0db4-c360-473445a78bd9.png">


### colorDodge

> **覆い焼きカラー**
> 
> 各チャンネルのカラー情報に基づき、基本色を明るくして基本色と合成色のコントラストを落とし、合成色を反映します。ブラックと合成しても変化はありません。[^8]

<img width="616" alt="Screen Shot 2020-07-12 at 18.45.48.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63842/01e6ea25-6875-c5fe-a600-6665ea634d1f.png">

### colorBurn

> **焼き込みカラー**
> 
> 各チャンネルのカラー情報に基づき、基本色を暗くして基本色と合成色のコントラストを強くし、合成色を反映します。ホワイトで合成した場合は、何も変更されません。[^9]

<img width="615" alt="Screen Shot 2020-07-12 at 18.46.01.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63842/23f4eefc-94e2-b38e-21ed-3bf7346d2193.png">

### softLight

> **ソフトライト**
> 
> 合成色に応じて、カラーを暗くまたは明るくします。画像上でスポットライトを照らしたような効果が得られます。合成色（光源）が 50 ％グレーよりも明るい場合、画像は覆い焼きされたかのように明るくなります。合成色が 50 ％グレーよりも暗い場合、画像は焼き込んだように暗くなります。純粋な黒または白でペイントすると、その部分の明暗ははっきりしますが、純粋な黒または白にはなりません。[^10]

<img width="615" alt="Screen Shot 2020-07-12 at 18.46.14.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63842/61efcbd4-1858-529d-5348-c04b14ccbca7.png">

### hardLight

> **ハードライト**
> 
> 合成色に応じて、カラーを乗算またはスクリーンします。画像上で直接スポットライトを照らしたような効果が得られます。合成色（光源）が 50 ％グレーよりも明るい場合、画像はスクリーンされたかのように明るくなります。これは、画像にハイライトを追加するときに役立ちます。合成色が 50 ％グレーよりも暗い場合、画像は乗算されたかのように暗くなります。これは、画像にシャドウを追加するときに役立ちます。純粋なホワイトまたはブラックでペイントすると、純粋なホワイトまたはブラックになります。[^11]

<img width="615" alt="Screen Shot 2020-07-12 at 18.46.26.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63842/a646e641-2355-e2be-7500-0a0b8fa4ce35.png">

### difference

> **差の絶対値**
> 
> 各チャンネル内のカラー情報に基づいて、合成色を基本色から取り除くか、基本色を合成色から取り除きます。明るさの値の大きい方のカラーから小さい方のカラーを取り除きます。ホワイトと合成すると基本色の値が反転しますが、ブラックと合成しても変化はありません。[^12]

ブレンド関数は次の通りです。

```math
B(c_b, c_s) = | c_b – c_s |
```

ホワイトとブレンドすると色反転ができます。

<img width="615" alt="Screen Shot 2020-07-12 at 18.46.43.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63842/e477d424-f73e-7393-f654-498b3fc6fd17.png">
<img width="495" alt="Screen Shot 2020-07-12 at 19.49.32.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63842/4c62102b-759e-5607-51f6-ea0602e02227.png">

### exclusion

> **除外**
> 
> 差の絶対値モードと似ていますが、効果のコントラストはより低くなります。ホワイトと合成すると、基本色の値が反転しますが、ブラックと合成しても変化はありません。[^13]

<img width="615" alt="Screen Shot 2020-07-12 at 18.46.59.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63842/8bbb7010-311a-a38f-517e-a62bee15e100.png">
<img width="495" alt="Screen Shot 2020-07-12 at 19.49.47.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63842/2c23f6b0-f3c0-e78e-4f8e-597bdf826f32.png">


### hue

> **色相**
> 
> ベースカラーの輝度と彩度、およびブレンドカラーの色相を持つ最終カラーが作成されます。[^14]

### saturation

> **彩度**
> 
> 基本色の輝度と色相および合成色の彩度を使用して、結果色を作成します。このモードで彩度ゼロ（グレー）の領域をペイントした場合は、何も変更されません。[^15]

### color

> **カラー**
> 
> 基本色の輝度と、合成色の色相および彩度を使用して、結果色を作成します。これにより、画像内のグレーレベルが保持され、モノクロ画像のカラー化およびカラー画像の階調化に役立ちます。[^16]

### luminosity

> **輝度**
> 
> 基本色の色相および彩度と、合成色の輝度を使用して、結果色を作成します。このモードでは、カラーモードの反対の効果が作成されます。[^17]
 
## サンプルコード

今回のスクリーンショットで使ったコードはこちら。

### 表示データ

```swift:Item.swift
import SwiftUI

struct Item: Hashable {
    let mode: BlendMode, name: String

    static var items: [Item] = [
        Item(mode: .normal,     name: ".normal"),
        Item(mode: .multiply,   name: ".multiply"),
        Item(mode: .screen,     name: ".screen"),
        Item(mode: .overlay,    name: ".overlay"),
        Item(mode: .darken,     name: ".darken"),
        Item(mode: .lighten,    name: ".lighten"),
        Item(mode: .colorDodge, name: ".colorDodge"),
        Item(mode: .colorBurn,  name: ".colorBurn"),
        Item(mode: .softLight,  name: ".softLight"),
        Item(mode: .hardLight,  name: ".hardLight"),
        Item(mode: .difference, name: ".difference"),
        Item(mode: .exclusion,  name: ".exclusion"),
    ]
}
```

### 画像と色のブレンド

```swift:ImageBlendView.swift
import SwiftUI

struct ImageBlendView: View {
    var color: Color
    var mode: BlendMode

    var body: some View {
        ZStack {
            Image("lenna").resizable().scaledToFit()
            Rectangle().fill(color).blendMode(mode)
        }
    }
}

struct ImageBlendViewSamples: View {
    var mode: BlendMode
    var colors: [Color] = [.clear, .red, .green, .blue, .black, .white]
    var body: some View {
        HStack(spacing: 0) { [colors, mode] in
            ForEach(0..<colors.count) { i in
                ImageBlendView(color: colors[i], mode: mode)
            }
        }
    }
}

// MARK: - PreviewProvider
struct ImageBlendViewSamples_Previews: PreviewProvider {
    static var previews: some View {
        Group { [items = Item.items] in
            ForEach(0..<items.count) { i in
                ImageBlendViewSamples(mode: items[i].mode)
                    .previewDisplayName(items[i].name)
            }
        }
        .previewLayout(.fixed(width: 100 * 6, height: 100))
    }
}
```

### 円のブレンド

```swift
import SwiftUI

struct ColorBlendView: View {
    var mode: BlendMode
    var background: Color
    var colors: [Color]
    var body: some View {
        GeometryReader<AnyView> { [mode, colors] geometry in
            let edge = min(geometry.size.width, geometry.size.height)/2
            let offset = edge/3
            let arg: (Int) -> CGFloat = { 2*(.pi)*CGFloat($0)/CGFloat(colors.count) - (.pi)/2 }
            return AnyView(
                ZStack {
                    ForEach(0..<colors.count) { index in
                        Circle()
                            .fill(colors[index])
                            .frame(width: edge)
                            .offset(x: offset*cos(arg(index)), y: offset*sin(arg(index)))
                            .blendMode(mode)
                    }
                }
                .frame(width: geometry.size.width, height: geometry.size.height)
            )
        }
        .background(background)
        .edgesIgnoringSafeArea(.all)
    }
}

struct ColorBlendViewSamples: View {
    var mode: BlendMode
    var foregrounds: [[Color]] = [
        [.red, .green, .blue],
        .init(repeating: .green, count: 7)
    ]
    var backgrounds: [Color] = [
        Color(red: 0, green: 0, blue: 0),
        Color(red: 1, green: 1, blue: 1),
    ]
    var body: some View {
        HStack(spacing: .zero) { [mode, foregrounds, backgrounds] in
            ForEach(0..<backgrounds.count) { i in
                ForEach(0..<foregrounds.count) { j in
                    ColorBlendView(mode: mode, background: backgrounds[i], colors: foregrounds[j])
                }
            }
        }
    }
}

// MARK: - PreviewProvider
struct ColorBlendViewSamples_Previews: PreviewProvider {
    static var previews: some View {
        Group { [items = Item.items] in
            ForEach(0..<items.count) { i in
                ColorBlendViewSamples(mode: items[i].mode)
                    .previewDisplayName(items[i].name)
            }
        }
        .previewLayout(.fixed(width: 120 * 4, height: 120))
    }
}
```

## 参考

- [CGBlendMode | Apple Developer Documentation](https://developer.apple.com/documentation/coregraphics/cgblendmode?language=objc)
- [BlendMode | Apple Developer Documentation](https://developer.apple.com/documentation/swiftui/blendmode)
- [Adobe PDF Reference Archives](https://www.adobe.com/devnet/pdf/pdf_reference_archive.html)
- [Adobe Photoshop での描画モード](https://helpx.adobe.com/jp/photoshop/using/blending-modes.html) 
- [標準画像データベース[神奈川工大 信号処理応用研究室]](http://www.ess.ic.kanagawa-it.ac.jp/app_images_j.html)

[^1]: [CGBlendMode | Apple Developer Documentation](https://developer.apple.com/documentation/coregraphics/cgblendmode?language=objc)
[^2]: [Adobe Photoshop での描画モード](https://helpx.adobe.com/jp/photoshop/using/blending-modes.html#通常) 
[^3]: [Adobe Photoshop での描画モード](https://helpx.adobe.com/jp/photoshop/using/blending-modes.html#乗算) 
[^4]: [Adobe Photoshop での描画モード](https://helpx.adobe.com/jp/photoshop/using/blending-modes.html#スクリーン) 
[^5]: [Adobe Photoshop での描画モード](https://helpx.adobe.com/jp/photoshop/using/blending-modes.html#オーバーレイ) 
[^6]: [Adobe Photoshop での描画モード](https://helpx.adobe.com/jp/photoshop/using/blending-modes.html#比較（暗）) 
[^7]: [Adobe Photoshop での描画モード](https://helpx.adobe.com/jp/photoshop/using/blending-modes.html#比較（明）) 
[^8]: [Adobe Photoshop での描画モード](https://helpx.adobe.com/jp/photoshop/using/blending-modes.html#覆い焼きカラー) 
[^9]: [Adobe Photoshop での描画モード](https://helpx.adobe.com/jp/photoshop/using/blending-modes.html#焼き込みカラー) 
[^10]: [Adobe Photoshop での描画モード](https://helpx.adobe.com/jp/photoshop/using/blending-modes.html#ソフトライト) 
[^11]: [Adobe Photoshop での描画モード](https://helpx.adobe.com/jp/photoshop/using/blending-modes.html#ハードライト) 
[^12]: [Adobe Photoshop での描画モード](https://helpx.adobe.com/jp/photoshop/using/blending-modes.html#差の絶対値) 
[^13]: [Adobe Photoshop での描画モード](https://helpx.adobe.com/jp/photoshop/using/blending-modes.html#除外) 
[^14]: [Adobe Photoshop での描画モード](https://helpx.adobe.com/jp/photoshop/using/blending-modes.html#色相) 
[^15]: [Adobe Photoshop での描画モード](https://helpx.adobe.com/jp/photoshop/using/blending-modes.html#彩度) 
[^16]: [Adobe Photoshop での描画モード](https://helpx.adobe.com/jp/photoshop/using/blending-modes.html#カラー) 
[^17]: [Adobe Photoshop での描画モード](https://helpx.adobe.com/jp/photoshop/using/blending-modes.html#輝度)