TAKE 2
前回「Metal + UIScrollView でズーム&スクロール可能な2Dアプリに挑戦」というタイトルで記事を書きました。今回はそのTAKE2です。前回の記事はこちらになります。
Metal + UIScrollView でズーム&スクロール可能な2Dアプリに挑戦
http://qiita.com/codelynx/items/8c631e51d107ef71a44b
その後も研究を重ねて、いくつかのブレイクスルーがありましたので、その成果を発表したいと思います。結論から言うとかなり前進し、Metal で 2D で ズーム・スクロール可能なプロダクティビティ的なアプリの開発の基礎が出来上がったのではないかと考えています。
作戦は前回発表した内容を踏襲しています。UIScrollView の背後に MTKView を配置します。そして、UIScrollView のサブビューにはダミーのコンテンツ View を配置し、UIScrollView もダミーコンテンツ View も背景を透明にします。そして、ユーザーは UIScrollView でズームやスクロールを行なっても、実際にはダミーのコンテンツ View は表示されませんが、その座標系を MTKView の座標系に変換し、コンテンツを描画する事で、実際には同じ場所に配置されている MTKView が UIScrollView に連動してズームやスクロールを行なっているように見えると言う話でした。
EDIT: その後修正を加えました。現状では、MTKViewが UIScrollView の全面に配置されるようになりました。UIScrollView もコンテンツ View も MTKView の背面で見えなくなるはずですが、代わりに UIScrollView の Gesture Recognizer 全てを MTKView に移し替えたので、MTKView上の全てのタッチは、実際には
Metal2DScrollable
今回のサンプルコードは前回と同じ GitHub のリポジトリにありますが、内容は更新されています。
起動すると、画面一面に世界地図が表示されますが、これは Metal のシェーダーでレンダリングされています。二本指でズームもできますし、二本指でスクロールもできます。そして一本指で画面をなぞると赤い点が連続して表示されます。これも Metal のポイントシェーダーで表示されます。最大3つのストロークまで表示されそれ以降は古いものより消えていきます。画面の右側にスクロールすると、GPU 系のデモ特有のカラーグラデーションの三角形が半透明で表示されています。これも、別のシェーダーで描画されています。そして、世界地図の中央あたりに、白い文字で時間を表示していますが、なんとこれは Core Graphics で表示されいます。実は MTKView のもう一つ全面にやはり透明な View が配置されており、Core Graphics の表示と合成させて表示させる事ができます。文字や Core Graphics の方が得意な描画はこの方法で実現する事が可能です。ただし、ズームやスクロールする操作では画面更新のタイミングなどに多少のズレが生じるため、少々の違和感は残ります。
(注) ~~~同じコードでも、iOS 9 と iOS 10 とで挙動が違うようです。なぜか上下が逆転しまうようです。原因は優先度下げ気味で調査中です。~~~原因がわかりました。iOS 10 では MTKTextureLoaderOptionOrigin が導入され、この挙動が変わったみたいです。この手の変更はオプションを入れない場合は、前のバージョンと同じ挙動にしてほしいです。現在は修正済み。
Components
今回のサンプルプログラムのアーキテクチャは以下のコンポーネントで構成されています。
- RenderView
- UIScrollView
- RenderContentView
- RenderableScene
- Renderer
- Renderable
- Shader
- RenderContext
RenderView
RenderView はこのアーキテクチャーの中心となります。UIScrollView や MTKView など付随する View などは自動的に生成されます。よって、Storyboard などに RenderView を配置する時は、UIScrollView や MTKView など付随する View の配置は不要です。それは、実行時に生成されます。
UIScrollView
UIScrollView は RenderView の subview として配置され、実際のズームやスクロールの操作はこの UIScrollView が実際に行います。デフォルトではスクロールは2本指となっていますが、これは、ペン入力などとの衝突回避の為であり、コードから変更する事ができます。
RenderContentView
RenderContentView は UIScrollView の subview として配置され、ズームやスクロール時には実際にはこの View がズームされたりスクロールされたりしています。もっとも、この View は透明なので、ユーザーの目にはうつりませんが。また、ユーザーのタッチイベントはこの View で受け取ります。よって、タッチ座標は正確に取得する事ができます。
RenderableScene
RenderableScene とそのサブクラスは、コンテンツがどのように表示されるか知っているものとします。表示されるコンテンツのサイズのプロパティも持ちその値は、UIScrollView の contentsSize に反映されます。実際に表示される全てのオブジェクトはここに集められます。
また、RenderContentView で受け取ったタッチイベントはここに転送されます。鉛筆や筆のような処理を行いたい場合は、ここでそれが行えます。
Renderer
Renderer は実際はプロトコルですが、 Render Pipeline や Vertex Descriptor, Color Sampler State などレンダリングに必要なフォーマットなどに関する情報を管理します。リソース軽減の為に同じタイプの Renderer は device (MTLDevice) につき一つになるようにプログラムしてください。
Renderable
Renderable は Renderer を実体化したものと言えます。例えば、Renderer はレンダリングに必要な頂点情報や色情報のフォーマットを知っていても、実際の形は知りませんが、Renderable はその座標や形さらに実際の色情報なども知っています。
Shader
最終的に Metal がレンダリングする実際のシェーダーです。Vertex Shader と Fragment Shader があります。Vertex Shader には必ず、ユニフォームまたはコンスタントとして、4x4 相当の Transform を含めてください。そして Vertex Shader は頂点情報をこの transform で変換して、Fragment Shader に渡してください。実際の ズームやスクロールの操作はこの transform の値を元に行われます。
開発者が独自のシェーダーを追加しい場合には、Renderer、Renderable、Shader の三点をセットにして開発します。これによって、シェーダーの数が増えても、シェーダーに関連するコードと、それを利用する側のコードが分離できて、コードに見渡しがよくなるはずです。
RenderContext
現時点ではあまり存在感がありませんが、RenderContext は Core Graphics の CGContext のように、Transform や Texture や色などを保持して、グループ化されたオブジェクトを回転させたり、より高度な表現する事が可能になるのではないかと考えています。
View の階層とレンダリングの過程
RenderView の階層は以下のようになります。
+ RenderView
+ UIScrollView
+ RenderContentView
+ MTKView
+ RenderDrawView
シェーダーが実際にレンダリングされる過程は以下のとおりになります。
MTKView -> RenderView -> RenderableScene -> Renderable -> Renderer -> Shader
RenderableScene の実装例
初期化時に CGSize(width: 2048, height: 1024)
を指定すれば、UIScrollView 内のコンテンツもそのように扱われます。 ImageRenderable など全ての Renderable の初期化時に device が必要になるので、結構面倒です。将来改善したいポイントの一つです。
class YourScene: RenderableScene {
var image = UIImage(named: "BlueMarble.png")! // 2048 x 1024
lazy var imageRenderable: ImageRenderable? = {
return ImageRenderable(device: self.device, image: self.image, frame: Rect(0, 0, 2048, 1024))
}()
override init?(device: MTLDevice, contentSize: CGSize) {
super.init(device: device, contentSize: contentSize)
}
override func render(in context: RenderContext) {
self.imageRenderable?.render(context: context)
}
}
UIViewController の実装例
class YourViewController: UIViewController {
@IBOutlet weak var renderView: RenderView!
var canvasScene: CanvasScene?
override func viewDidLoad() {
assert(renderView != nil)
super.viewDidLoad()
let device = self.renderView.device
self.canvasScene = CanvasScene(device: device, contentSize: CGSize(width: 2048, height: 1024))
self.renderView.renderableScene = self.canvasScene
}
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
}
}
Renderer 実装例
ColorRenderer の Color を Your にしただけじゃないかという疑惑がありますが、その通りです。主に、Vertex のフォーマットや Render Pipeline State を実装します。多くのプロパティが lazy になっていうのは初期化に device が必要だからです。将来なんとかしたいです。そして func render(context: RenderContext, vertexBuffer: VertexBuffer<Vertex>)
の実装です。これで、実際のレンダリングが走ります。
import Foundation
import MetalKit
import GLKit
class YourRenderer: Renderer {
typealias VertexType = Vertex
private static var deviceRendererTable = NSMapTable<MTLDevice, YourRenderer>.weakToStrongObjects()
class func yourRenderer(for device: MTLDevice) -> YourRenderer {
if let renderer = YourRenderer.deviceRendererTable.object(forKey: device) {
return renderer
}
let renderer = YourRenderer(device: device)
YourRenderer.deviceRendererTable.setObject(renderer, forKey: device)
return renderer
}
// MARK: -
var device: MTLDevice
var pixelFormat: MTLPixelFormat = .bgra8Unorm
// MARK: -
init(device: MTLDevice) {
self.device = device
}
struct Vertex {
var x, y, z, w, r, g, b, a: Float
}
struct Uniforms {
var transform: GLKMatrix4
}
var vertexDescriptor: MTLVertexDescriptor {
let vertexDescriptor = MTLVertexDescriptor()
vertexDescriptor.attributes[0].offset = 0
vertexDescriptor.attributes[0].format = .float2
vertexDescriptor.attributes[0].bufferIndex = 0
vertexDescriptor.attributes[1].offset = MemoryLayout<Float>.size * 4
vertexDescriptor.attributes[1].format = .float4
vertexDescriptor.attributes[1].bufferIndex = 0
vertexDescriptor.layouts[0].stepFunction = .perVertex
vertexDescriptor.layouts[0].stride = MemoryLayout<Vertex>.size
return vertexDescriptor
}
lazy var library: MTLLibrary = {
return self.device.newDefaultLibrary()!
}()
lazy var renderPipelineState: MTLRenderPipelineState = {
let renderPipelineDescriptor = MTLRenderPipelineDescriptor()
renderPipelineDescriptor.vertexDescriptor = self.vertexDescriptor
renderPipelineDescriptor.vertexFunction = self.library.makeFunction(name: "your_vertex")!
renderPipelineDescriptor.fragmentFunction = self.library.makeFunction(name: "your_fragment")!
renderPipelineDescriptor.colorAttachments[0].pixelFormat = self.pixelFormat
renderPipelineDescriptor.colorAttachments[0].isBlendingEnabled = true
renderPipelineDescriptor.colorAttachments[0].rgbBlendOperation = .add
renderPipelineDescriptor.colorAttachments[0].alphaBlendOperation = .add
renderPipelineDescriptor.colorAttachments[0].sourceRGBBlendFactor = .sourceAlpha
renderPipelineDescriptor.colorAttachments[0].sourceAlphaBlendFactor = .sourceAlpha
renderPipelineDescriptor.colorAttachments[0].destinationRGBBlendFactor = .oneMinusSourceAlpha
renderPipelineDescriptor.colorAttachments[0].destinationAlphaBlendFactor = .oneMinusSourceAlpha
let renderPipelineState = try! self.device.makeRenderPipelineState(descriptor: renderPipelineDescriptor)
return renderPipelineState
}()
lazy var colorSamplerState: MTLSamplerState = {
let samplerDescriptor = MTLSamplerDescriptor()
samplerDescriptor.minFilter = .nearest
samplerDescriptor.magFilter = .linear
samplerDescriptor.sAddressMode = .repeat
samplerDescriptor.tAddressMode = .repeat
return self.device.makeSamplerState(descriptor: samplerDescriptor)
}()
func render(context: RenderContext, vertexBuffer: VertexBuffer<Vertex>) {
var uniforms = Uniforms(transform: context.transform)
let uniformsBuffer = device.makeBuffer(bytes: &uniforms, length: MemoryLayout<Uniforms>.size, options: MTLResourceOptions())
let commandEncoder = context.commandEncoder
commandEncoder.setRenderPipelineState(self.renderPipelineState)
commandEncoder.setVertexBuffer(vertexBuffer.buffer, offset: 0, at: 0)
commandEncoder.setVertexBuffer(uniformsBuffer, offset: 0, at: 1)
commandEncoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: vertexBuffer.count)
}
func vertexBuffer(for vertices: [Vertex]) -> VertexBuffer<Vertex>? {
return VertexBuffer<Vertex>(device: device, vertices: vertices)
}
func vertices(for rect: Rect, color: UIColor) -> [Vertex] {
let l = rect.minX, r = rect.maxX, t = rect.minY, b = rect.maxY
var red = CGFloat(1), green = CGFloat(1), blue = CGFloat(1), alpha = CGFloat(1)
color.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
let _r = Float(red), _g = Float(green), _b = Float(blue), _a = Float(alpha)
return [
Vertex(x: l, y: t, z: 0, w: 1, r: _r, g: _g, b: _b, a: _a),
Vertex(x: l, y: b, z: 0, w: 1, r: _r, g: _g, b: _b, a: _a),
Vertex(x: r, y: b, z: 0, w: 1, r: _r, g: _g, b: _b, a: _a),
Vertex(x: l, y: t, z: 0, w: 1, r: _r, g: _g, b: _b, a: _a),
Vertex(x: r, y: b, z: 0, w: 1, r: _r, g: _g, b: _b, a: _a),
Vertex(x: r, y: t, z: 0, w: 1, r: _r, g: _g, b: _b, a: _a),
]
}
}
Renderable実装例
Renderer はフォーマットなどの形式しか知らないので、それに沿って、座標や色やテクスチャなど与えてレンダリングできる形にしてあげます。
typealias YourVertex = YourRenderer.Vertex
class YourRectRenderable: Renderable {
let device: MTLDevice
let renderer: YourRenderer
let vertexBuffer: VertexBuffer<YourVertex>
var frame: Rect
var color: UIColor
init?(device: MTLDevice, frame: Rect, color: UIColor) {
self.device = device
self.frame = frame
self.color = color
var r: CGFloat = 0
var g: CGFloat = 0
var b: CGFloat = 0
var a: CGFloat = 0
color.getRed(&r, green: &g, blue: &b, alpha: &a)
let renderer = YourRenderer.yourRenderer(for: device)
let vertices = renderer.vertices(for: frame, color: color)
guard let vertexBuffer = renderer.vertexBuffer(for: vertices) else { return nil }
self.renderer = renderer
self.vertexBuffer = vertexBuffer
}
func render(context: RenderContext) {
self.renderer.render(context: context, vertexBuffer: vertexBuffer)
}
}
Shader の実装例
やはり Metal や基本的な GPU の知識は必要となります。VertexIn は頂点情報のフォーマットを定義していますが、position
や color
は予約キーワード(Qualifier)で、頂点情報の attribute(0) は position(座標で) float 四個分 で、attribute(1) は色情報でこれも Float 四個分である事を示します。これらのフォーマットに関する定義は、YourRenderer の MTLVertexDescriptor で厳密に定義されています。
Uniforms は VertexDescriptor は不要ですが、buffer(1) は render() メソッド内の commandEncoder.setVertexBuffer(uniformsBuffer, offset: 0, at: 1)
の 1
に対応しています。
VertexOut の position
も予約キーワードで、構造体のどれが座標の情報なのかがわかるようになっています。Vertex Shader から Fragment Shader へ付加的な情報を渡したい場合は、ここに何かを追加します。
#include <metal_stdlib>
using namespace metal;
struct VertexIn {
packed_float4 position [[ attribute(0) ]];
packed_float4 color [[ attribute(1) ]];
};
struct VertexOut {
float4 position [[ position ]];
float4 color;
};
struct Uniforms {
float4x4 transform;
};
vertex VertexOut your_vertex(
device VertexIn * vertices [[ buffer(0) ]],
constant Uniforms & uniforms [[ buffer(1) ]],
uint vid [[ vertex_id ]]
) {
VertexOut outVertex;
VertexIn inVertex = vertices[vid];
outVertex.position = uniforms.transform * float4(inVertex.position);
outVertex.color = float4(inVertex.color);
return outVertex;
}
fragment float4 your_fragment(
VertexOut vertexIn [[ stage_in ]]
) {
return vertexIn.color;
}
課題
- Renderable に対して Renderer を device に対して一つしか生成しない仕組みに改善の余地あり。
- 例えば、全ての Renderer は colorAttachments の pixelFormat が全て一致してなければならず、コーディングに注意を払う以外効果的な方法がない。
- RenderView Architecture の部品化・フレームワーク化
- Renderer、Renderable の Protocol Extension 的なリファクタリング
まとめ
Metal の Programming はそれでも高度な知識が必要で複雑です。しかしこの方法を使えば、シェーダーとクライアントコードをより分離して扱うことができるので、Custom Shader の開発とクライアントコードの開発により注力できるようになのではないかと考えています。
環境に関する表記
Xcode Version 8.2 (8C38)
Apple Swift version 3.0.2 (swiftlang-800.0.63 clang-800.0.42.1)