iOSアプリで画面上のQRコード部分のみ輝度を上げ（WWDC 2022, iOS 16, Metal, EDRレンダリング）

WWDC 2022で発表されたMetalの新機能では、EDRを使って画面画像の一部分だけを明るく（現在設定されている画面の明るさ以上）することができます。

| 部屋の明かりを消して最小限の明るさの画面 |
|---|
| <img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635330/197e8906-0111-275a-49cc-58ca63e24fd3.jpeg"> |
| 一番下のQRコードは通常のImageビューです |


:::note
※Apple Developerの公開Session/Documentation/Sample Codeページだけを使ってこの記事を執筆しました。
:::

# 背景

QRコード決済画面を表示する場合、通常、画面全体の輝度が高くなり（夜間に開くと気分が悪くなることがある。。。）

また、アプリ側で輝度の設定と復元を手動で行う必要があります。

# WWDC 2022で公開された新機能

Metalフレームワークの新しいダイナミックEDR（Extended Dynamic Range）レンダリングサポートを使用すると、

* QRコード画像のみを最大輝度で表示（ユーザーの現在の画面輝度設定に関係なく）
* 画面の他の部分はユーザーが設定した明るさを維持
* 画面表示の明るさを変更する必要がない
* さらに、EDRを使えば、QRコードも画面の最大輝度より明るくすることができるので、より読み取りやすくなります。

**この記事では、この新機能をUIKitとSwiftUIの両方で使用することについて説明します。**

# 実装

:::note
EDRはHDRと似ています。画像に保存されている輝度値を利用して、特定の画素を他の画素よりも明るくするものです。iPhoneの場合、輝度値が1より大きいと、現在設定されている画面の明るさよりも明るく表示されます。
:::


QRコードの `CIImage` は、明るさの値を画面がサポートする最大値に設定して作成することができます。
そして、この画像をMetalフレームワークを使ってレンダリングし、iPhoneの画面のEDR機能を利用できるようにします。

## GitHubリポジトリ

まずは試してみたいという方は、以下のソースコードをiPhoneで実行してみてください。

:::note warn
効果をよりよく確認するために iPhoneの画面の明るさを最低にし、アプリを実行してください。
:::


:::note warn
シミュレータでは、EDRはサポートされていません。
:::

https://github.com/mszpro/QR-Code-EDR

## EDR対応テスト

上記のように、EDRは画像上の特定のピクセルを、現在設定されている画面の明るさよりも明るく表示することができます。

デバイスがそれをサポートしているかどうかは、`UIScreen.main.currentEDRHeadroom` または `view.window?.screen.currentEDRHeadroom` を使用してテストする必要があります。値が>1である場合、その端末はEDRをサポートしています。


## ステップ1．QRコードの作成

まず、`CIFilter` の一種である `CIQRCodeGenerator` を用いてQRを作成する。

`inputMessage` でQRコードの文字列内容を指定します。

```swift
// ここでのコードは、ステップ6で使用されます

guard let qrFilter = CIFilter(name: "CIQRCodeGenerator") else {
    return nil
}

let qrCodeContent = "testing-highlighted-qr"
let inputData = qrCodeContent.data(using: .utf8)
qrFilter.setValue(inputData, forKey: "inputMessage")
qrFilter.setValue("H", forKey: "inputCorrectionLevel")

guard let image = qrFilter.outputImage else {
    return nil
}

let sizeTransform = CGAffineTransform(scaleX: 10, y: 10)
let qrImage = image.transformed(by: sizeTransform)
```

[ソースファイルに表示](https://github.com/mszpro/QR-Code-EDR/blob/main/EDR%20QR/Views/HighlightedQRCodeView.swift#L23...L36)

## ステップ2．画面の最大輝度を計算する

iPhoneは、それぞれ最大輝度の値が異なります。
また、環境やバッテリー残量によって変化することもあります。

:::note
ヘッドルーム `headroom` と呼ばれる、iPhoneの最大輝度値を取得する

```swift
// ここでのコードは、ステップ6で使用されます

let screen = view.window?.screen
var headroom = CGFloat(1.0)
if #available(iOS 16.0, *) {
    headroom = screen?.currentEDRHeadroom ?? 1.0
}
```

:::

コンテンツが明るすぎるのが嫌な場合は、`min` で現在のヘッドルームと固定値（8など）の間の最小値を取得することができます。

## ステップ3．明るさの塗りつぶしレイヤーを生成する

ここで、上記で算出した明るさの最大値で塗りつぶしレイヤーを生成します。
ここでの色は通常の色とは異なり、明るさを表すもので、1.0より大きくすることができますので、ご注意ください。

```swift
// ここでのコードは、ステップ6で使用されます

let maxRGB = headroom
guard let EDR_colorSpace = CGColorSpace(name: CGColorSpace.extendedLinearSRGB),
      let maxFillColor = CIColor(red: maxRGB, green: maxRGB, blue: maxRGB,
                                 colorSpace: EDR_colorSpace) else {
    return nil
}
let fillImage = CIImage(color: maxFillColor)
```

[ソースファイルに表示](https://github.com/mszpro/QR-Code-EDR/blob/main/EDR%20QR/Views/HighlightedQRCodeView.swift#L38...L45)

## ステップ4．QR画像と輝度レイヤーを合成する

ここで、生成されたQRコードに明るさの値を与えて、最終的な画像を生成します。
これを行うには、QR画像 `qrImage` をマスクとして使用し、塗りつぶしレイヤー `fillImage`（最大輝度のレイヤー）を切り取ります。

```swift
// ここでのコードは、ステップ6で使用されます

let maskFilter = CIFilter.blendWithMask()

maskFilter.maskImage = qrImage
maskFilter.inputImage = fillImage

guard let combinedImage = maskFilter.outputImage else {
    return nil
}

return combinedImage.cropped(to: CGRect(x: 0, y: 0,
                                width: 300 * scaleFactor,
                                height: 300 * scaleFactor))
```

[ソースファイルに表示](https://github.com/mszpro/QR-Code-EDR/blob/main/EDR%20QR/Views/HighlightedQRCodeView.swift#L47...L59)

## ステップ5．レンダラーをセットアップする

さて、`CIImage` をレンダリングするレンダラーを作る必要があります。
そして、レンダラーの設定で、EDRを使用するように指示します。

まず、`MTKViewDelegate` 型に準拠した `Renderer` クラスを作成する。
この `Renderer` クラスは、Metalビュー `MTKView` のデリゲートになります
また、Metal で画像をレンダリングし、`CIImage` で作業するための適切なフレームワークをインポートする必要があります。

```swift
import Metal
import MetalKit
import CoreImage

class Renderer: NSObject, MTKViewDelegate, ObservableObject {
    
    let imageProvider: (_ contentScaleFactor: CGFloat, _ headroom: CGFloat) -> CIImage? // 画像データを提供する呼び出し側デリゲート関数
    
    public let device: MTLDevice? = MTLCreateSystemDefaultDevice()
    
    let commandQueue: MTLCommandQueue?
    let renderContext: CIContext? // 名前、キャッシュ環境設定、低電力設定の設定
    let renderQueue = DispatchSemaphore(value: 3) // 新しいフレームを描画する前に、前のレンダリングが完了するのを待つために使用される
    
    init(imageProvider: @escaping (_ contentScaleFactor: CGFloat, _ headroom: CGFloat) -> CIImage?) {
        self.imageProvider = imageProvider
        self.commandQueue = self.device?.makeCommandQueue()
        if let commandQueue {
            self.renderContext = CIContext(mtlCommandQueue: commandQueue,
                                       options: [.name: "QR-Code-Renderer",
                                                 .cacheIntermediates: true,
                                                 .allowLowPower: true])
        } else {
            self.renderContext = nil
        }
        super.init()
    }
    
    func draw(in view: MTKView) {
        // ToDo
    }
    
    func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {
        // 描画可能なサイズや向きの変更に対応する。
    }
    
}
```

[ソースファイルに表示](https://github.com/mszpro/QR-Code-EDR/blob/main/EDR%20QR/Model/Renderer.swift)

:::note
`renderContext` 変数には、ログでのデバッグを容易にするため、レンダラーの名前を設定します。
`renderQueue` 変数には、最大値 3 の `DispatchSemaphore` を設定します。
:::

### ディスパッチセマフォ `DispatchSemaphore`の使用

これは、プログラムが次のレンダリングを開始する前に、前のレンダリングが完了するのを待つためのツールです。

前のコマンドが終了するのを待つには、次のようにします。

`_ = renderQueue.wait(timeout: DispatchTime.distantFuture)` は、前のタスクが終了するまで、プログラムは次のコードの行の実行を停止します。

前のコマンドが終了したことをシステムに知らせるには、`self.renderQueue.signal()` を実行します。

### `draw` 関数を完成させる

次に、コンテンツを描画する `func draw(in view: MTKView)` 関数に取り組みます。

```swift
class Renderer: NSObject, MTKViewDelegate, ObservableObject {
    
    // ... 上記のコードは、変数とinit関数です。 //
    
    func draw(in view: MTKView) {
        
        guard let commandQueue else { return }

        // wait for previous render to complete
        _ = renderQueue.wait(timeout: DispatchTime.distantFuture)
        
        if let commandBuffer = commandQueue.makeCommandBuffer() {
            
            // コマンドが完了したら、次のフレームをレンダリングできるようにキューに通知する。
            commandBuffer.addCompletedHandler { (_ commandBuffer)-> Swift.Void in
                self.renderQueue.signal()
            }
            
            if let drawable = view.currentDrawable {
                
                let drawSize = view.drawableSize
                let contentScaleFactor = view.contentScaleFactor
                let destination = CIRenderDestination(width: Int(drawSize.width),
                                                      height: Int(drawSize.height),
                                                      pixelFormat: view.colorPixelFormat,
                                                      commandBuffer: commandBuffer,
                                                      mtlTextureProvider: { () -> MTLTexture in
                    return drawable.texture
                })
                
                // サポートされる最大EDR値（ヘッドルーム）を計算する
                var headroom = CGFloat(1.0)
                if #available(iOS 16.0, *) {
                    headroom = view.window?.screen.currentEDRHeadroom ?? 1.0
                }
                
                // デリゲート関数から表示するCI画像を取得します。
                guard var image = self.imageProvider(contentScaleFactor, headroom) else {
                    return
                }

                // ビューの可視領域で画像を中央に配置します。
                let iRect = image.extent
                let backBounds = CGRect(x: 0,
                                        y: 0,
                                        width: drawSize.width,
                                        height: drawSize.height)
                let shiftX = round((backBounds.size.width + iRect.origin.x - iRect.size.width) * 0.5)
                let shiftY = round((backBounds.size.height + iRect.origin.y - iRect.size.height) * 0.5)
                image = image.transformed(by: CGAffineTransform(translationX: shiftX, y: shiftY))
                
                // 画像が透明の場合、背景を指定する
                image = image.composited(over: .gray)
                
                // テクスチャーデスティネーションにレンダリングするタスクを開始します。
                guard let renderContext else { return }
                _ = try? renderContext.startTask(toRender: image, from: backBounds,
                                                  to: destination, at: CGPoint.zero)
                
                // レンダリング結果を表示し、レンダリングタスクをコミットする
                commandBuffer.present(drawable)
                commandBuffer.commit()
            }
        }
    }
    
    func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {
        // 描画可能なサイズや向きの変更に対応する。
    }
    
}
```

[ソースファイルに表示](https://github.com/mszpro/QR-Code-EDR/blob/main/EDR%20QR/Model/Renderer.swift)

:::note
上記のコードでは、
まず `renderQueue.wait` 関数を使用して、前のレンダリングが完了するのを待ちます。
次に、コマンドバッファを取得し、描画のサイズを指定します。
HDRで提供できる最大の明るさ（ヘッドルーム）を計算します。
そして、CI画像オブジェクトをメタルビュー内でレンダリングし、レンダリング画像を中央に配置する。
:::


## ステップ6．レンダラーを初期化する

さて、QRコードと輝度値を含む生成したCIImage（手順1～手順4のコード）と、Rendererクラス（手順5）を元に、レンダラーを初期化します。

```swift
let renderer = Renderer(imageProvider: { (scaleFactor: CGFloat, headroom: CGFloat) -> CIImage? in
    
    // QRコード画像を生成する
    guard let qrFilter = CIFilter(name: "CIQRCodeGenerator") else {
        return nil
    }
    
    let qrCodeContent = "testing-highlighted-qr"
    let inputData = qrCodeContent.data(using: .utf8)
    qrFilter.setValue(inputData, forKey: "inputMessage")
    qrFilter.setValue("H", forKey: "inputCorrectionLevel")
    
    guard let image = qrFilter.outputImage else {
        return nil
    }
    
    let sizeTransform = CGAffineTransform(scaleX: 10, y: 10)
    let qrImage = image.transformed(by: sizeTransform)
    
    // 空白の塗りつぶし画像を生成する
    let maxRGB = headroom
    let maxFillColor = CIColor(red: maxRGB, green: maxRGB, blue: maxRGB,
                               colorSpace: CGColorSpace(name: CGColorSpace.extendedLinearSRGB)!)!
    let fillImage = CIImage(color: maxFillColor)
    
    // マスクフィルターを使って、最終的なQRコード画像を作成します。
    let maskFilter = CIFilter.blendWithMask()
    maskFilter.maskImage = qrImage
    maskFilter.inputImage = fillImage
    
    // ハイライトレイヤーとQR画像を合成する
    guard let combinedImage = maskFilter.outputImage else {
        return nil
    }
    return combinedImage.cropped(to: CGRect(x: 0, y: 0,
                                            width: 512.0 * scaleFactor,
                                            height: 384.0 * scaleFactor))
})
```

[ソースファイルに表示](https://github.com/mszpro/QR-Code-EDR/blob/main/EDR%20QR/Views/HighlightedQRCodeView.swift#L20...L59)

## ステップ7．メタルビューでQRコードを表示する

それでは、メタルビューにQRコードを表示してみましょう。

```swift
@StateObject var renderer: Renderer // 変数

let metalView = MTKView(frame: .zero, device: renderer.device)

// MetalKitを通じてCore Animationに、ビューの再描画頻度を提案する。
metalView.preferredFramesPerSecond = 10

// Core Imageがメタルコンピュートパイプラインを使用してビューにレンダリングできるようにします。
metalView.framebufferOnly = false
metalView.delegate = renderer

if let layer = metalView.layer as? CAMetalLayer {
    // SDRより大きな値をサポートする色空間でEDRを有効にする。
    if #available(iOS 16.0, *) {
        layer.wantsExtendedDynamicRangeContent = true
    }
    layer.colorspace = CGColorSpace(name: CGColorSpace.extendedLinearDisplayP3)
    // レンダービューがEDRのピクセル値をサポートしていることを確認する。
    metalView.colorPixelFormat = MTLPixelFormat.rgba16Float
}
```

:::note
上記のコードでは、
新しいMetalビュー MTKView を初期化し、
リフレッシュレート（フレーム毎秒）を10に設定し、
Renderer インスタンスに delegate を設定し、
EDR（Extended Dynamic Range）を使用するように設定しています。
:::


UIKitを使用している場合は、このビューをそのままUIKitのビューに追加することができます `view.addSubview(metalView)`

## SwiftUI互換のメタルレンダリングビューを作る

アダプターを使うことで、SwiftUIと互換性のあるメタルビューを作ることもできます。

```swift
import SwiftUI
import MetalKit

struct MetalView: ViewRepresentable {
    
    @StateObject var renderer: Renderer
    /// - Tag: MakeView
    func makeView(context: Context) -> MTKView {
        let view = MTKView(frame: .zero, device: renderer.device)

        // MetalKitを通じてCore Animationに、ビューの再描画頻度を提案する。
        view.preferredFramesPerSecond = 10

        // Core Imageがメタルコンピュートパイプラインを使用してビューにレンダリングできるようにします。
        view.framebufferOnly = false
        view.delegate = renderer

        if let layer = view.layer as? CAMetalLayer {
            // SDRより大きな値をサポートする色空間でEDRを有効にする。
            if #available(iOS 16.0, *) {
                layer.wantsExtendedDynamicRangeContent = true
            }
            layer.colorspace = CGColorSpace(name: CGColorSpace.extendedLinearDisplayP3)
            // レンダービューがEDRのピクセル値をサポートしていることを確認する。
            view.colorPixelFormat = MTLPixelFormat.rgba16Float
        }
        return view
    }
    
    func updateView(_ view: MTKView, context: Context) {
        configure(view: view, using: renderer)
    }
    
    private func configure(view: MTKView, using renderer: Renderer) {
        view.delegate = renderer
    }
}
```

[ソースファイルに表示](https://github.com/mszpro/QR-Code-EDR/blob/main/EDR%20QR/Views/MetalView_SwiftUICompatible.swift)

## SwiftUIビューでレンダラーを使用する

さて、初期化されたレンダラーのインスタンスと、上で作成した互換性のあるビューアダプターの両方を使用して、ハイライトされたQRコード画像を表示する単一のSwiftUIビューを作成することができます。

```swift
import SwiftUI
import CoreImage.CIFilterBuiltins

/// - Tag: ContentView
struct ContentView: View {
    var body: some View {
        // 独自のレンダラーを持つメタルビューを作成します。
        let renderer = Renderer(imageProvider: { (scaleFactor: CGFloat, headroom: CGFloat) -> CIImage? in
            
            // QRコード画像を生成する
            guard let qrFilter = CIFilter(name: "CIQRCodeGenerator") else {
                return nil
            }
            
            let qrCodeContent = "testing-highlighted-qr"
            let inputData = qrCodeContent.data(using: .utf8)
            qrFilter.setValue(inputData, forKey: "inputMessage")
            qrFilter.setValue("H", forKey: "inputCorrectionLevel")
            
            guard let image = qrFilter.outputImage else {
                return nil
            }
            
            let sizeTransform = CGAffineTransform(scaleX: 10, y: 10)
            let qrImage = image.transformed(by: sizeTransform)
            
            // 空白の塗りつぶし画像を生成する
            let maxRGB = headroom
            let maxFillColor = CIColor(red: maxRGB, green: maxRGB, blue: maxRGB,
                                       colorSpace: CGColorSpace(name: CGColorSpace.extendedLinearSRGB)!)!
            let fillImage = CIImage(color: maxFillColor)
            
            // マスクフィルターを使って、最終的なQRコード画像を作成します。
            let maskFilter = CIFilter.blendWithMask()
            maskFilter.maskImage = qrImage
            maskFilter.inputImage = fillImage
            
            // ハイライトレイヤーとQR画像を合成する
            guard let combinedImage = maskFilter.outputImage else {
                return nil
            }
            return combinedImage.cropped(to: CGRect(x: 0, y: 0,
                                            width: 512.0 * scaleFactor,
                                            height: 384.0 * scaleFactor))
        })

        MetalView(renderer: renderer)
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}
```

[ソースファイルに表示](https://github.com/mszpro/QR-Code-EDR/blob/main/EDR%20QR/Views/HighlightedQRCodeView.swift)

ここで、iPhoneで実行すると、QRコードだけがハイライトされるのが見えます。

また、異なる塗りつぶしレイヤーを生成することで、画像の異なる部分を強調することができます。また、明るさの値を1より小さくすることで、特定の部分をより暗くすることができます。

---

お読みいただきありがとうございました。

[☺️ Twitter @MszPro](https://twitter.com/MszPro)
[🐘 Mastodon @me@mszpro.com](https://sns.mszpro.com/@me)

<img width="150" src="https://cdn.mszmagic.com/static-web-content/AppClipImage_small.png">

# 関連記事

・[iOSアプリで画面上のQRコード部分のみ輝度を上げ](https://qiita.com/MaShunzhe/items/5dfb4dbfca2a25cbaddf)

・[UICollectionViewの行セル、ヘッダー、フッター、またはUITableView内でSwiftUIビューを使用](https://qiita.com/MaShunzhe/items/3d98fa897aa1bcffc74b)


・[iPhone 14 ProのDynamic Islandにウィジェットを追加し、Live Activitiesを開始する](https://qiita.com/MaShunzhe/items/6cc8d9d204f40cbee19c)


・[iOS 16：秘密値の保存、FaceID認証に基づく個人情報の表示/非表示（LARight）](https://qiita.com/MaShunzhe/items/07c1e65277a5008106db)


・[iOS16 MapKitの新機能 : 地図から場所を選ぶ、通りを見回す、検索補完](https://qiita.com/MaShunzhe/items/693e6302876707d5684d)


・[SwiftUIアプリでバックグラウンドタスクの実行(ネットワーク、プッシュ通知) (BackgroundTasks, URLSession)](https://qiita.com/MaShunzhe/items/4e0d1032748fe4f398de)


・[WWDC22、iOS16：iOSアプリに画像からテキストを選択する機能を追加（VisionKit）](https://qiita.com/MaShunzhe/items/ed3985d05975bf35292d)


・[WWDC22、iOS16：数行のコードで作成できるSwiftUIの新機能（26本）](https://qiita.com/MaShunzhe/items/0ed36d8a6de7c56b1d34)


・[WWDC22、iOS 16：SwiftUIでChartsフレームワークを使ってチャートを作成する](https://qiita.com/MaShunzhe/items/cae62af8b07f4f474fd8)


・[WWDC22, iOS 16: WeatherKitで気象データを取得](https://qiita.com/MaShunzhe/items/dce45ccdd05c1f70cf54)


・[WWDC 2022の基調講演のまとめ記事](https://qiita.com/MaShunzhe/items/037580d2ed51a5a6e9d9)

---

SwiftUI初心者向けの本とビデオ講座を制作中です。

私のTwitterをフォローしていただければ、出版時に無料クーポンやサンプルを差し上げます。

最新情報は、私のTwitterをフォローしてください。

---

**☺️** 個人ウェブサイト <https://MszPro.com>

![writing-quickly_emoji_400.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635330/640938e9-c121-8152-b8c4-8ab63124afcc.png)

Written by MszPro\~