これまでの挑戦
去年の年末頃からチャレンジしていた Metal で Scroll と Zoom の実装ですが、今年初め頃に「Basic Metal 2D」という記事を書きました。iPad Pro 発売後、Metal で 2D アプリを書こうとして、2〜3ヶ月程頑張って Metal を勉強しましたが、小生のポンコツな線形代数の影響で、途中で頓挫し、そのまとめとして、Basic Metal 2D を公開しました。このコードは、シェーダーとシェーダーでレンダリングされるオブジェクトを抽象化して分離し、Metal でより簡単に使えるようにと狙ったものでした。
Basic Metal 2D
http://qiita.com/codelynx/items/9daf221f6c87276acc3a
https://github.com/codelynx/BasicMetal2D
さて今回も堤さんに召喚されて、やはりあれと戦わなくてはならぬと思いましたが、今回は秘策がありました。今年後半に思いついた、UIScrollView とダミーの content view を使い、表示する View を実際にズームスクロールする事なく、ズームやスクロールしたように見えるという手法です。
なぜそんな事が必要かというと UIScrollView 内でズームされた subview は例えば4倍にズームされても、実際は1倍の画像を引き伸ばしただけで大変見栄えが悪いのと、それを回避しようと contentScaleFactor を倍率に応じて設定すると、subview の4倍の大きさのイメージをラスタライズする必要があり、倍率を上げるとどこかでメモリ不足でクラッシュしてしまうからです。詳しくはこちらのリンクを参照してください。
UIScrollView内でPDFなどのベクター表示をうまく拡大表示する方法
http://qiita.com/codelynx/items/a2a87b053f8225782a9c
Metal2DScrollable
さて、今回はこのアイディアを Metal でも利用できると思いつきました。UIScrollView を利用すれば、独自に PinchGesture や PanGesture を組み合わせて、UIScrollView ぽい事をするよりも、より普段使い慣れた UIScrollView の挙動に近いからと思ったからです。
Metal2DScrollable
結論から言うとこのサンプルコードは完成していません。完成してからポストしたいと思ったのですが、Advent カレンダーに間に合わなくなるからです(笑)。説明は後述します。
Viewの構造
View の階層は以下のような構造になっています。UIScrollView の背面には MTKView と OvalView がいます。MTKView は Metal 関連の View ですが、Oval View は Core Graphics で 同等の事をする為の View です。確認用なので、白い楕円を4つ書いているだけです。そして、UIScrollView の下には contentView という実際にはただの UIView を配置しています。contentView は等倍の時に画面一杯に表示されるようになっています。そして、UIScrollView には任意の最大倍率を設定します。この場合は4を設定していますが、さらに大きくても構わないはずです。UIScrollView と contentView の背景は透明に設定します。
+ Metal2DViewController
+ View
- MTKView
- OvalView
+ UIScrollView
- contentView (UIView)
そして、pinch や pan の操作をすると、UIScrollView は、contentView をズームしたりスクロールさせたりしますが、contentView は透明で見えません。そして、OvalView と MTKView の描画する番が回ってきます。OvalView は contentView の bounds を 自分の座標系に変換します。が、ここでは 変換した結果の CGRect よりは CGAffineTransform が欲しいので、ポンコツの線形代数を駆使して、こんな関数を用意します。
extension CGRect {
func transform(to rect: CGRect) -> CGAffineTransform {
var t = CGAffineTransform.identity
t = t.translatedBy(x: -self.minX, y: -self.minY)
t = t.scaledBy(x: 1 / self.width, y: 1 / self.height)
t = t.scaledBy(x: rect.width, y: rect.height)
t = t.translatedBy(x: rect.minX * self.width / rect.width, y: rect.minY * self.height / rect.height)
return t
}
}
これで、ある CGRect から別の CGRect 座標変換する CGAffineTransform が得られます。そこで、OvalView の bounds を 座標変換後の contentView の bounds に変換できる transform が得られました。これを CGContext に concat すれば、あとは、OvalView は自分の bounds にめがけて描画すれば実際は contentView でズームされた座標に変換され結果的に、ズームされたり、スクロールされたような結果が得られます。
MTKView
さて、MTKView の場合です。描画についてはその delegate が面倒をみる事になっているので、この場合は Metal2DViewController
が行います。今回はほぼ、Metal のテンプレートから生成されたコードなのですが、これをそのまま利用する事とします。これをズームとスクロールできれば目的達成です。
まずはシェーダーに手を入れます。シェーダーでは座標を変換したいので、変換用のデータを Uniforms
の構造体に渡す事にします。そして、Vertex Shader の引数に constant Uniforms & uniforms [[ buffer(2) ]]
を追加します。「2」の意味は「0」「1」が既に使われているから、その次の「2」です。
そして、Vertex Shader では Uniforms
バッファー内の modelViewProjectionMatrix
(注:名前が行けてないな) の積をとって、座標変換して Fragment Shader に送られます。
using namespace metal;
struct VertexInOut
{
float4 position [[position]];
float4 color;
};
struct Uniforms {
float4x4 modelViewProjectionMatrix;
};
vertex VertexInOut vertex_shader(uint vid [[ vertex_id ]],
constant packed_float4* position [[ buffer(0) ]],
constant packed_float4* color [[ buffer(1) ]],
constant Uniforms & uniforms [[ buffer(2) ]]
){
VertexInOut outVertex;
outVertex.position = uniforms.modelViewProjectionMatrix * float4(position[vid]);
outVertex.color = color[vid];
return outVertex;
};
fragment half4 fragment_shader(VertexInOut inFrag [[stage_in]])
{
return half4(inFrag.color);
};
Vertex Shader の変更に伴い MTKView の delegate も変更します。Uniforms
struct を定義して、buffer を作ります。初期値は GLKMatrix4Identity
でいいでしょう。私の場合はこんな具合に GLKit でも利用できるものは利用しています。
struct Uniforms {
var modelViewProjectionMatrix: GLKMatrix4
}
func loadAssets() {
// ...
let transform: GLKMatrix4 = GLKMatrix4Identity
var uniforms = Uniforms(modelViewProjectionMatrix: transform)
uniformsBuffer = device.makeBuffer(bytes: &uniforms, length: MemoryLayout<Uniforms>.size, options: [])
uniformsBuffer.label = "uniforms"
次に draw()
メソッド内で、uniform buffer を 「2」番目に割り当てます。これで、uniform buffer の内容が vertex shader に伝わります。
func draw(in view: MTKView) {
// ...
renderEncoder.setVertexBuffer(vertexBuffer, offset: 256*bufferIndex, at: 0)
renderEncoder.setVertexBuffer(vertexColorBuffer, offset:0 , at: 1)
renderEncoder.setVertexBuffer(uniformsBuffer, offset: 0, at: 2) // <== here!
renderEncoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 9, instanceCount: 1)
そして、UIScrollViewDelegate を実装します。スクロールやズームが発生した時は、ovalView を再描画させてあげます。今回、MTKView はずっとアニメーションし続けていますが、そうでないケースでは、明示的に self.mtkView.setNeedsDisplay()
を呼ぶ必要があるかもしれません。MTKView の enableSetNeedsDisplay や isPaused などのステートにも注意してください。
class Metal2DViewController: UIViewController, MTKViewDelegate, UIScrollViewDelegate {
// ...
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return self.contentView
}
public func scrollViewDidScroll(_ scrollView: UIScrollView) {
self.ovalView.setNeedsDisplay()
// self.mtkView.setNeedsDisplay()
}
public func scrollViewDidZoom(_ scrollView: UIScrollView) {
self.ovalView.setNeedsDisplay()
// self.mtkView.setNeedsDisplay()
}
そしていよいよ、座標変換です。冒頭で説明した通りまだ、期待通りの動作にはいたっていません。実験しやすいように、switch 文で区分けています。
class Metal2DViewController: ... {
// ...
func draw(in view: MTKView) {
// ...
let uniformPtr = UnsafeMutablePointer<Uniforms>(OpaquePointer(uniformsBuffer.contents()))
let method = 1
switch method {
case 0:
// (case0) somehow this code not working -- any idea?
let targetRect = self.contentView.convert(self.contentView.bounds, to: self.mtkView)
var t1 = self.mtkView.bounds.transform(to: targetRect)
uniformPtr.pointee.modelViewProjectionMatrix = GLKMatrix4(transform: t1)
case 1:
// (case1) compute transform from UIScrollView's contentOffset and zoomScale -- not quite right
var transform = CGAffineTransform.identity
let offsetX = self.scrollView.contentOffset.x / (self.mtkView.bounds.width * 2.0)
let offsetY = self.scrollView.contentOffset.y / (self.mtkView.bounds.height * 2.0)
transform = transform.translatedBy(x: -offsetX, y: -offsetY)
transform = transform.scaledBy(x: self.scrollView.zoomScale, y: self.scrollView.zoomScale)
uniformPtr.pointee.modelViewProjectionMatrix = GLKMatrix4(transform: transform)
case 2:
// (case2) only scaling -- scaling looks OK, but no scrolling
let scale = scrollView.zoomScale
let transform = CGAffineTransform.identity.scaledBy(x: scale, y: scale)
uniformPtr.pointee.modelViewProjectionMatrix = GLKMatrix4(transform: transform)
default:
break
}
本命は case 0
ですが、translate の移動量大きすぎるせいか他に原因があるのか、ズームするとすぐ画面からいなくなってしまいます。case 1
は UIScrollView の zoomScale と contentOffset から transform を作っていますが、どこか微妙に計算がずれているようです。そして、case 2
は UIScrollView の zoomScale のみを適用して、transform を作って、uniform buffer に送っています。スクロールに関してはダメですが、ズームのみに関しては期待通りの動作をします。
結構頑張って、直そうとしていましたが、これ以上遅くなると、記事を書く時間がなくなってしまうので、ここで打ち切りにしました。線形代数に自信のある方は是非、チャレンジしてみてください。そしてプルリクお待ちしております。
スクリーンショット
今回のサンプルコードのスクリーンショットです。白い楕円は OvalView が表示しているもので、うまく利用できれば、Metal と Core Graphics のコラボができるかもしれません。
環境に関する表記
Xcode Version 8.1 (8B62)
Apple Swift version 3.0.1 (swiftlang-800.0.58.6 clang-800.0.42.1)