iOSアプリで画面上のQRコード部分のみ輝度を上げ(WWDC 2022, iOS 16, Metal, EDRレンダリング)
WWDC 2022で発表されたMetalの新機能では、EDRを使って画面画像の一部分だけを明るく(現在設定されている画面の明るさ以上)することができます。
WWDC 2022で発表されたMetalの新機能では、EDRを使って画面画像の一部分だけを明るく(現在設定されている画面の明るさ以上)することができます。
部屋の明かりを消して最小限の明るさの画面
※Apple Developerの公開Session/Documentation/Sample Codeページだけを使ってこの記事を執筆しました。
背景
QRコード決済画面を表示する場合、通常、画面全体の輝度が高くなり(夜間に開くと気分が悪くなることがある。。。)
また、アプリ側で輝度の設定と復元を手動で行う必要があります。
WWDC 2022で公開された新機能
Metalフレームワークの新しいダイナミックEDR(Extended Dynamic Range)レンダリングサポートを使用すると、
- QRコード画像のみを最大輝度で表示(ユーザーの現在の画面輝度設定に関係なく)
- 画面の他の部分はユーザーが設定した明るさを維持
- 画面表示の明るさを変更する必要がない
- さらに、EDRを使えば、QRコードも画面の最大輝度より明るくすることができるので、より読み取りやすくなります。
この記事では、この新機能をUIKitとSwiftUIの両方で使用することについて説明します。
実装
EDRはHDRと似ています。画像に保存されている輝度値を利用して、特定の画素を他の画素よりも明るくするものです。iPhoneの場合、輝度値が1より大きいと、現在設定されている画面の明るさよりも明るく表示されます。
QRコードの CIImage
は、明るさの値を画面がサポートする最大値に設定して作成することができます。
そして、この画像をMetalフレームワークを使ってレンダリングし、iPhoneの画面のEDR機能を利用できるようにします。
GitHubリポジトリ
まずは試してみたいという方は、以下のソースコードをiPhoneで実行してみてください。
効果をよりよく確認するために iPhoneの画面の明るさを最低にし、アプリを実行してください。
シミュレータでは、EDRはサポートされていません。
EDR対応テスト
上記のように、EDRは画像上の特定のピクセルを、現在設定されている画面の明るさよりも明るく表示することができます。
デバイスがそれをサポートしているかどうかは、UIScreen.main.currentEDRHeadroom
または view.window?.screen.currentEDRHeadroom
を使用してテストする必要があります。値が>1である場合、その端末はEDRをサポートしています。
ステップ1.QRコードの作成
まず、CIFilter
の一種である CIQRCodeGenerator
を用いてQRを作成する。
inputMessage
でQRコードの文字列内容を指定します。
// ここでのコードは、ステップ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)
ステップ2.画面の最大輝度を計算する
iPhoneは、それぞれ最大輝度の値が異なります。
また、環境やバッテリー残量によって変化することもあります。
ヘッドルーム headroom
と呼ばれる、iPhoneの最大輝度値を取得する
// ここでのコードは、ステップ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より大きくすることができますので、ご注意ください。
// ここでのコードは、ステップ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)
ステップ4.QR画像と輝度レイヤーを合成する
ここで、生成されたQRコードに明るさの値を与えて、最終的な画像を生成します。
これを行うには、QR画像 qrImage
をマスクとして使用し、塗りつぶしレイヤー fillImage
(最大輝度のレイヤー)を切り取ります。
// ここでのコードは、ステップ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))
ステップ5.レンダラーをセットアップする
さて、CIImage
をレンダリングするレンダラーを作る必要があります。
そして、レンダラーの設定で、EDRを使用するように指示します。
まず、MTKViewDelegate
型に準拠した Renderer
クラスを作成する。
この Renderer
クラスは、Metalビュー MTKView
のデリゲートになります
また、Metal で画像をレンダリングし、CIImage
で作業するための適切なフレームワークをインポートする必要があります。
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) {
// 描画可能なサイズや向きの変更に対応する。
}
}
renderContext
変数には、ログでのデバッグを容易にするため、レンダラーの名前を設定します。renderQueue
変数には、最大値 3 の DispatchSemaphore
を設定します。
ディスパッチセマフォ DispatchSemaphore
の使用
これは、プログラムが次のレンダリングを開始する前に、前のレンダリングが完了するのを待つためのツールです。
前のコマンドが終了するのを待つには、次のようにします。
_ = renderQueue.wait(timeout: DispatchTime.distantFuture)
は、前のタスクが終了するまで、プログラムは次のコードの行の実行を停止します。
前のコマンドが終了したことをシステムに知らせるには、self.renderQueue.signal()
を実行します。
draw
関数を完成させる
次に、コンテンツを描画する func draw(in view: MTKView)
関数に取り組みます。
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) {
// 描画可能なサイズや向きの変更に対応する。
}
}
上記のコードでは、
まず renderQueue.wait
関数を使用して、前のレンダリングが完了するのを待ちます。
次に、コマンドバッファを取得し、描画のサイズを指定します。
HDRで提供できる最大の明るさ(ヘッドルーム)を計算します。
そして、CI画像オブジェクトをメタルビュー内でレンダリングし、レンダリング画像を中央に配置する。
ステップ6.レンダラーを初期化する
さて、QRコードと輝度値を含む生成したCIImage(手順1~手順4のコード)と、Rendererクラス(手順5)を元に、レンダラーを初期化します。
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))
})
ステップ7.メタルビューでQRコードを表示する
それでは、メタルビューにQRコードを表示してみましょう。
@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
}
上記のコードでは、
新しいMetalビュー MTKView を初期化し、
リフレッシュレート(フレーム毎秒)を10に設定し、
Renderer インスタンスに delegate を設定し、
EDR(Extended Dynamic Range)を使用するように設定しています。
UIKitを使用している場合は、このビューをそのままUIKitのビューに追加することができます view.addSubview(metalView)
SwiftUI互換のメタルレンダリングビューを作る
アダプターを使うことで、SwiftUIと互換性のあるメタルビューを作ることもできます。
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
}
}
SwiftUIビューでレンダラーを使用する
さて、初期化されたレンダラーのインスタンスと、上で作成した互換性のあるビューアダプターの両方を使用して、ハイライトされたQRコード画像を表示する単一のSwiftUIビューを作成することができます。
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()
}
}
ここで、iPhoneで実行すると、QRコードだけがハイライトされるのが見えます。
また、異なる塗りつぶしレイヤーを生成することで、画像の異なる部分を強調することができます。また、明るさの値を1より小さくすることで、特定の部分をより暗くすることができます。
お読みいただきありがとうございました。