- 投稿日:2020-07-22T21:33:48+09:00
初めてのiPhoneアプリをつくってわかったこと。とっかかりは家族の誕生日
2020年中にiOSエンジニア転職を目指している、imafumiです。
「自分でつくってみる」ことに、「とっかかり」がなかったのが、仕事の帰り道にふとした思いつきで、着手できたお話です。
自分と同じくポートフォリオになかなか取り掛かれない初学者の方に、「こんなんでもとりあえず作った方が良さそうだな」と思っていただければ幸いです。
私はとりあえず作った方がいいと思いつつも、自分がつくりたいものと自分のスキルが解離しすぎていて、何から手をつければいいかわからない状態でした。
その頃の自分に言ってあげたい。
「こんなレベルでも、家族には喜んでもらえたよ。」
「具体的な家族(身近な人)に向けてと考えると、必要なスキルはそう多くなく、しかもリアクションがモチベーションと勉強につながるよ」
「とりあえず作ると、改善点はいくらでも思いつくよ。」家族の誕生日に何か特別なことができないか
家族の誕生日当日、仕事の帰り、すでに誕生日プレゼントはその日までに渡していたものの、当日何もしないのもなあ・・・そうだ、世界に一つのアプリを作ってプレゼントしようと、ふと思いつく。
こんなんだったら、作れるかも。こんなんだったら、すぐにできて、喜んでもらえるかも。
- お祝いされる人へ「誕生日おめでとう」と表示
- 家族の名前のボタンがあり、押すと祝う人の写真が表示される。
- お祝いする家族それぞれのお祝いのメッセージが表示される。
すごくシンプル。今ならできそう。
誕生日は残り3時間。その間に作りきる。
主なコードはこれだけ。
import UIKit class ViewController: UIViewController { @IBOutlet weak var person: UIImageView! @IBOutlet weak var textMassage: UILabel! //以下、家族人分。メッセージ内容は恥ずかしいので、変えてます。 var XXXXMessage = "いつも、ありがとう" @IBAction func toXxxx(_ sender: UIButton) { person.image = UIImage(named: "xxx" ) textMassage.text = xxxxMessage } }結果、なんとかつくって23:58に見せることができました(ギリギリ)
相手は、ベッドでうつらうつらしていたので、「すごーい・・・」スヤスヤという感じのリアクションでしたが嬉しかった!(迷惑でごめんなさい)
次の日の朝、「なんか昨日見せてもらった気がするけど、夢かな?」と言っていました。
少し改良したものを見せて、今度はしっかりと喜んでくれました。リアクションから意外と重要だったアプリのポイント
アプリのアイコンの見た目がインパクト半分
寝ぼけてみたときはほとんどここに対する反応(「かわいー」)だった気がする。
※アイコンはネットで探したHappy Birthdayのカラフルな文字画像。どこをさわればいいか、ぱっとわかるようにするのは難しい
構成要素が主に3つしかないのに、それでも一瞬どうすればいいのか迷っていた。同じことを繰り返しても(同じボタンを連打しても)何か変化があるようにする
4歳の娘は同じボタンを連打し、動かない画面に不思議そうにしていた。(当初一つのボタンに一つしか写真とメッセージを用意していなかったので、同じボタンを押しても、同じ画像と同じ文字で動かなかった)//import以降のまったく変えていないところは省略しています。 var num = 0 func numPlus() { num += 1 if num == 3 { num = 0 } } //以下、家族人分。メッセージ内容は恥ずかしいので、変えてます。 var XXXXMessage = ["いつもありがとう。", "お誕生日おめでとう", "これからもよろしくー"] @IBAction func toXxxx(_ sender: UIButton) { //画像3つ(xxx0,xxx1,xxx2)用意。 person.image = UIImage(named: "xxx\(num)" ) textMassage.text = xxxxMessage[num] numPlus() }
ひとまず作りきった。でも、反応をみるとドンドン変えたいところが出てくる!
上にも書きましたが、実際の反応をみると、気づくことがいっぱい。
とりあえず、以下の点を次の日の朝すぐ変えました。
- どこを押せばいいかはっきりと文章で書き、目立つようにした。レイアウトも変えた。
- 一人の家族につき、3つの画像と3つのメッセージを用意して、ループさせた(同じボタンを押しても、どんどん画面が変わる)
- ボタンを押す画面の前に、起動画面(黒い背景に白い文字で 「〇〇、誕生日おめでとう!」)を追加した。
まだまだいっぱいあふれてきました。一部、下の「おまけ」にメモしています。
そのほかに気づいたこと
つまったところ
- 画像が横をむいた
- 背景色と文字色のうまい組み合わせが難しかった。意外と配色は難しい
- 本では見返せるが、Udemyだと見返すのは難しい
- 本の箇所を思い出すのは意外に難しい
- メソッド以外のところで式を書こうとしてエラーになった
- 最初の画面がすぐに消えたので、みえなかった
- エラーが出た(拡張子大文字)
- labelでは一行しか入力できなかった
- UITextViewで明朝体の表示になった
- オートレイアウトしたら、逆にぐちゃぐちゃになった
- アプリのアイコンがうまく変換できなかった文字の切り替えにforを使おうとしたらうまくいかなかった(インクリメントとif文で解決)
今後の改善検討点
- 画像を取得できるようにする 一人3コ (別プランなら10コ?)
- メッセージを取得できるようにする 一人3コ(別プランなら10コ?)
- 画像とメッセージのセットをランダムに取り出す
- 少しずつフェードインするようなエフェクトをかける
- 最初の表示でクラッカーをならす
- 最初の表示でポーン(プロフェッショナルの音のイメージ)と音を出す
- ロウソクをタップで消せるゲームをつくる
- レイアウトをiPhone7以外にも適用する
- Twitterやフェイスブックに投稿できるようにする
- zoomに共有できるようにする
長くなってしましました。
最後まで読んでいただき、ありがとうございました。
- 投稿日:2020-07-22T20:56:09+09:00
leadingAnchor/trailingAnchorとleftAnchor/rightAnchorについて
コードベースで書いていて少し気になりました。知っている人も多いと思いますが、僕は知らなかったので書いておきます。
はじめに
Anchor:錨。Swiftでは、端っこという認識で大丈夫です。
constraintについては、以下では制約という言葉を使います。本題
leadingAnchorのlead:先頭
trailingAnchorのtrailing:末端察しのいい人ならわかるかもしれません。
フォーム(LabelやTextField)をつくる場合
英語は左から右に読むので、
leading:先頭は 左 trailing:末端は 右
ということになります。逆に、右から左に読むヘブライ語は、
leading:先頭は 右 trailing:末端は 左
ということになります。Appleの公式の見解
基本的には、leftAnchor/trailingAnchorを使うべし。
絶対的な左と右の指定がない限りは、leading/trailing を使うべきだそうです。
例えば、右から左に進んでいくアプリを作るときなどに、ボタンがどうしても左に必要な場合は、leftAnchor/rightAnchor の制約を使うようにしましょう。終わりに
コードベースで書いていたときに気になったので、調べて忘れないようにアウトプットしておきました。間違いや気になる点がありましたら、コメントにておねがいします!
- 投稿日:2020-07-22T16:11:39+09:00
CocoaPodsを含むXcodeプロジェクトがビルドできない時
あるWebサイトからXcodeのサンプルワークスペースをダウンロードし、手元の環境でビルドしようとしたが失敗した。
エラーを調べるとワークスペースに含まれるCocoaPodsが手元のXcodeのバージョンではうまく参照できていないないようだった。
そこでCocoaPodsを再取得したところ現在のビルドできるようになった。その手順を以下に示す。
(全てのケースで上手くいくとは限らないが参考までに)
- プロジェクトのディレクトリは以下のようになっているはず。この時、Sample.xcworkspace をXcodeで開いてもビルドできなかった。
% ls -l total 16 -rw-rw-r--@ 1 koki staff 314 8 8 2018 Podfile -rw-r--r-- 1 koki staff 386 8 8 2018 Podfile.lock drwxr-xr-x 9 koki staff 288 8 8 2018 Pods drwxrwxr-x@ 11 koki staff 352 9 25 2018 Sample drwxrwxr-x@ 5 koki staff 160 8 8 2018 Sample.xcodeproj drwxr-xr-x@ 5 koki staff 160 8 8 2018 Sample.xcworkspace
- サンプル作成時、
pod install
コマンドによって追加されたはずであるファイルを削除する。% rm -rf Podfile.lock Pods RxSample.xcworkspace % ls -l total 16 -rw-rw-r--@ 1 koki staff 314 8 8 2018 Podfile drwxrwxr-x@ 11 koki staff 352 9 25 2018 Sample drwxrwxr-x@ 5 koki staff 160 7 22 12:47 Sample.xcodeproj
- CocoaPodsを再取得する
% pod install % ls -l total 16 -rw-rw-r--@ 1 koki staff 314 8 8 2018 Podfile -rw-r--r-- 1 koki staff 386 7 22 12:47 Podfile.lock drwxr-xr-x 9 koki staff 288 7 22 12:47 Pods drwxrwxr-x@ 11 koki staff 352 9 25 2018 Sample drwxrwxr-x@ 5 koki staff 160 7 22 12:47 Sample.xcodeproj drwxr-xr-x@ 5 koki staff 160 7 22 12:48 Sample.xcworkspace以上の手順を実施した後、Sample.xcworkspace をXCodeで開くとビルドが成功するようになった。
- 投稿日:2020-07-22T12:08:03+09:00
Swift iOSアプリ 1つ前の画面に値を渡す方法
前の画面に値を渡したい
プロトコルを作成することで解決できる。
「画面1 ← 画面2」という風に画面遷移するときはこんな感じ画面2.swift//自分で新しいプロトコル作っちゃう。名前はテキトウ protocol CatchProtocol { func catchData(count: Int) } class NextViewController: UIViewController { //自分で定義したプロトコルをdelegateって名前で変数として持つで!! var delegate:CatchProtocol? //渡したい値を宣言しとこかな var count = 1000 //戻るボタン押した時の挙動をここに書いちゃお @IBAction func back(_ sender: Any) { //delegateのcatchDataメソッドを発動してくれよな!引数に渡したい値書いちゃう delegate?.catchData(count: count) //前の画面(画面1)に戻るで dismiss(animated: true, completion: nil) } }画面1.swift//CatchProtcolを使いますよ〜って書く ↓ここ class ViewController: UIViewController, CatchProtocol { //この変数で画面2の値を受け取る var recepter = Int() //画面が遷移した時に呼ばれるメソッド prepare for segue を用意 override func prepare(for segue: UIStoryboardSegue, sender: Any?) { //次の画面を変数に入れる let nextVC = segue.destination as! NextViewController //オイラのクラスでデリゲートを受け持つで!! nextVC.delegate = self } //プロトコルで用意したメソッドの中身をここで書く //戻るボタン押したらここが呼ばれることになっとるぜ。(画面2のコード参照) func catchData(count: Int) { //ここで受け渡しが完了! recepter = count } }
- 投稿日:2020-07-22T10:50:03+09:00
(Swift)FirebaseのCloud Functionsを使用し、AppからSubCollection及びDocumentsを再帰的削除する方法
初めてCloudFunctionsを使用し、SubCollectionを削除しようとしたところハマったので備忘録として記事に残します。以下のコードにご指摘等ありましたらコメントください。
環境
Xcode:Version 11.5
Swift 5.0この記事はこんな人用です:
- iOSアプリを開発中
- FireStoreを使用している。
- 既にCloudFunctionsの環境を構築済み
- FirebnaseAuthまたはUIを使用している。
問題
FirestoreDBに保存しているデータを削除するとき、AppからDelete処理を行なっていたが、
Collection/Document/SubCollection1/"Document"/SubCollection2/Document
のデータ構造を持つデータの"Document"を削除した時に、SubCollection2以下が削除されずに残ったままになっていることがわかった。やりたかったこと
TableViewでデータのdocument削除処理を行った時に、SubCollection2以下を"Document"を削除する処理で、まとめて削除する方法はないものか???
Firebaseの公式資料を見てわかったこと
ドキュメントを削除しても、そのサブコレクション内のドキュメントは削除されない!
コレクション ツリー全体を安全かつ効率的に削除する Cloud Functions 関数の記述は可能!!“Cloud Firestore でのデータの削除は、特にリソースが制限されるモバイルアプリからは、以下の理由により、正しく行えない場合があります。
* コレクションをアトミックに削除するオペレーションが存在しない。
* ドキュメントを削除しても、そのサブコレクション内のドキュメントは削除されない。
* ドキュメントに動的なサブコレクションが存在する場合は、指定されたパスでどのデータを削除すればよいかを認識するのが困難になる可能性がある。
* 500 を超えるドキュメントのコレクションを削除するには、複数回の書き込みバッチ オペレーションまたは数百回の単純削除が必要である。
* 多くのアプリでは、エンドユーザーにコレクション全体を削除する権限を付与するのは適切ではない。
ただし、コレクション全体やコレクション ツリー全体を安全かつ効率的に削除する Cloud Functions 関数の記述は可能です。”
https://firebase.google.com/docs/firestore/solutions/delete-collections#javaCloud Functionsの実装
公式サイトの動画インストラクションに従い、CloudFunctionsの環境構築。
https://firebase.google.com/docs/functions/get-startedハマりポインント1:Firebase CLIの設定
生成したトークンをどこに設定したらいいかわからなかった。
$ firebase login
//トークンの生成
$ firebase login:ci.
//生成したトークンをConfigファイルに設定
$ firebase functions:config:set fb.token =“FIREBASE TOKEN”https://firebase.google.com/docs/cli#install-cli-mac-linux
ハマりポインント2:CloudFunctionに書くコード
公式Documentで紹介されているコードをそのまま使うことができなかったため変更)
https://firebase.google.com/docs/firestore/solutions/delete-collections#java
変更部分:
1.const firebase_tools = require("firebase-tools")
公式Documentのままのコードだと、await firebase_tools.firestore.deleteでエラーが発生するため。2.(!(context.auth?.uid)
Appでユーザーがログインしていれば削除を実施できるように。3.const path = data;
App内のコードでCouldFunction関数をコール時にDocumentReferenceを引数でそのまま渡そうとしたところエラーが発生したたため、App内のコードでpathを直接引数に。
https://firebase.google.com/docs/firestore/solutions/delete-collections#java変更後のコード:
const firebase_tools = require("firebase-tools"); exports.recursiveDelete = functions .runWith({ timeoutSeconds: 540, memory: '2GB' }) .https.onCall(async (data, context) => { // Only allow admin users to execute this function. if (!(context.auth?.uid)) { throw new functions.https.HttpsError( 'permission-denied', 'Must be an authorized user to initiate delete.' ); } const path = data; console.log( `User ${context.auth.uid} has requested to delete path ${path}` ); // Run a recursive delete on the given document or collection path. // The 'token' must be set in the functions config, and can be generated // at the command line by running 'firebase login:ci'. await firebase_tools.firestore .delete(path, { project: process.env.GCLOUD_PROJECT, recursive: true, yes: true, token: functions.config().fb.token }); return { path: path }; });ハマりポインント3:AppからCloudFunctionを呼び出す
作成した関数をApp内でどうやって呼び足したらいいか、パスを渡したらいいかわからない・・公式DocumentにSwiftのコードだけ書いていない・・・・
参考コード:
//削除したいDocumentのパス let documentRef: DocumentReference = Firestore.firestore().collection("your collection name").document("your document ID").collection("your collection name").document("your document ID") //CloudFunction内の関数を呼び出すためしょり let deleteFn = functions.httpsCallable("recursiveDelete") deleteFn.call(documentRef.path){ (result, error) in if let error = error as NSError? { if error.domain == FunctionsErrorDomain { let code = FunctionsErrorCode(rawValue: error.code) let message = error.localizedDescription let details = error.userInfo[FunctionsErrorDetailsKey] print("User has requested to delete:\(documentRef.path) and failed with error; code:\(code), message:\(message),details:\(details)") } //Delete処理失敗 }else{ //Delete処理成功 print("User has requested to delete \(documentRef.path) and succeeded") } }
- 投稿日:2020-07-22T10:46:13+09:00
Metal を使って10万個のパーティクルを描画しよう
はじめに
Metal を使うとたくさんの計算を並列で行うことができます. 60fps で画面の更新をする場合, 1フレームあたりの処理は約 16ms で収めなければいけません. UIKit を使って10万個の UIView の frame を更新しながら 60fps を保つのは難しいでしょう. Metal を使うとどんな計算が可能なのか, 簡単なサンプルアプリを実装したら勉強になった箇所がたくさんあったので, 共有と備忘録を兼ねてまとめたいと思います.
環境
- Xcode 11.5
- iPhone 11 Pro, iPhone XS, iPad Pro 11inch 第2世代 1
つくったもの
10万個のパーティクル 10万個のパーティクル(白黒) 設定画面 端末の画面いっぱいに最大で10万個のパーティクルがアニメーションします. ある程度可変なパラメータがあった方が理解に繋がると思ったので, パーティクルの色と背景色, パーティクルの個数は設定画面から指定できるようになっています.
ソースコードは naru-jpn/100000-particles で公開しています.
大まかな処理の流れ
大まかに処理の流れを図示すると上の図のようになります.
- それぞれのパーティクルの位置情報などを更新 2
- 1. で更新された情報をもとにして, それぞれのパーティクルを画面上に描画
という流れです. シンプルな構成です. Metal を使用する場合はパイプラインという処理の流れを記述する必要があり, 大まかな流れをイメージすることは大事なことだと思います. 60fps を維持したいので, これらの一連の処理は 16ms 以内で行う必要があります. 記事の後半にパフォーマンスについても記載しています.
実装の解説
具体的な実装について順を追って解説します. 記載しているコードは要点が分かるように部分的に簡略化しています.
パーティクルを表す構造体
ShaderTypes.h#include <simd/simd.h> typedef struct { vector_float4 color; vector_float2 position; vector_float2 velocity; float phase; } particle_t;上の構造体がパーティクルの実体です. RGBA の色情報, 2次元の座標上の位置と速度, 横方向の揺れを制御する為の変数から構成されています. この構造体は Swift のプログラムだけからではなくシェーダプログラムからも使いたいので, Swift の Struct ではなくこのように定義をする必要があります. 今回は定義を共有するものはこれだけなので, 直接このファイルを Bridging Header に指定します.
パーティクルを格納するバッファ
Renderer.swiftlet length: Int = MemoryLayout<particle_t>.size * Renderer.maxNumberOfParticles let buffer: MTLBuffer = device.makeBuffer(length: length, options: .storageModeShared)必要な領域のサイズを指定して, バッファを作成しています.
options
に.storageModeShared
を指定していますが, これは CPU と GPU の両方からこのバッファの内容を編集したいからです. 3パーティクルの初期化
Renderer.swiftlet particleBuffer = particleBuffers[0].contents().bindMemory(to: particle_t.self, capacity: numberOfParticles) for index in 0..<numberOfParticles { particleBuffer[index] = particle_t.create(with: setting, viewportSize: viewportSize) }バッファの内容を編集するために
bindMemory
で型を指定してUnsafeMutablePointer<particle_t>
に変換しています. 位置情報やそれぞれのパーティクルの色情報などをここで初期化しています.描画のサイクル
Renderer.swiftfunc draw(in view: MTKView) { // 定期的に呼ばれるデリゲートメソッド let semaphore = inFlightSemaphore // トリプルバッファリングの制御 _ = semaphore.wait(timeout: DispatchTime.distantFuture) do { let simulateSemaphore = simulationInFlightSemaphore // _ = simulateSemaphore.wait(timeout: DispatchTime.distantFuture) guard let commandBuffer = commandQueue.makeCommandBuffer() else { fatalError("Failed to make command buffer.") } // 1. それぞれのパーティクルの位置情報などを更新 simulate(in: view, commandBuffer: commandBuffer) commandBuffer.addCompletedHandler { _ in simulateSemaphore.signal() } commandBuffer.commit() } do { guard let commandBuffer = commandQueue.makeCommandBuffer() else { fatalError("Failed to make command buffer.") } // 2. 1. で更新された情報をもとにして, それぞれのパーティクルを画面上に描画 render(in: view, commandBuffer: commandBuffer) commandBuffer.addCompletedHandler { _ in semaphore.signal() } commandBuffer.commit() } currentBufferIndex = (currentBufferIndex + 1) % Renderer.maxInFlightRenderingBuffers }上の関数は
MTKViewDelegate
に定義されている関数で,MTKView
の再描画が必要なタイミングでこの関数が呼び出されます. セマフォの操作等 4 をしていますが, ここで大事なのはsimulate
とrender
の2行です. この2つの処理が, 「大まかな処理の流れ」で説明した2つのステップに対応しています.1. それぞれのパーティクルの位置情報などを更新
Swift側の処理
Renderer.swiftguard let function = library.makeFunction(name: "simulate") else { fatalError("Failed to make function simulate.") } do { // 関数 'simulate' を使う ComputePipeline を定義 simulatePipelineState = try device.makeComputePipelineState(function: function) } // ... private func simulate(in view: MTKView, commandBuffer: MTLCommandBuffer) { // ... // 上で定義したパイプライン(simulatePipelineState)に従って処理をする computeEncoder.setComputePipelineState(simulatePipelineState) computeEncoder.setBuffer(particleBuffers[currentBufferIndex], offset: 0, index: 0) // 入力 computeEncoder.setBuffer(particleBuffers[simulatedBufferIndex], offset: 0, index: 1) // 出力 computeEncoder.setBytes(&viewportSize, length: MemoryLayout<vector_float2>.size, index: 2) // 画面サイズ computeEncoder.setThreadgroupMemoryLength(simulatePipelineState.threadExecutionWidth * MemoryLayout<particle_t>.size, index: 0) computeEncoder.dispatchThreads(dispatchThreads, threadsPerThreadgroup: threadsPerThreadgroup) computeEncoder.endEncoding() }
library.makeFunction(name: "simulate")
でsimulate
という関数をとってきて,device.makeComputePipelineState(function: function)
でその関数を使うパイプラインを定義しています. 指定した情報はcomputeEncoder
によって GPU に渡す為の命令にエンコードされます. 関数simulate
の定義はShaders.metal
ファイル内にあり, そのような関数はシェーダ関数と呼ばれます.シェーダ側の処理
Shaders.metalkernel void simulate(device particle_t* currentParticles [[ buffer(0) ]], // 入力 device particle_t* newParticles [[ buffer(1) ]], // 出力 constant vector_uint2 *viewportSize [[ buffer(2) ]], // 画面サイズ const uint gid [[ thread_position_in_grid ]]) { // 更新前のパーティクル情報 float2 position = currentParticles[gid].position; float2 velocity = currentParticles[gid].velocity; float4 color = currentParticles[gid].color; float phase = currentParticles[gid].phase; float end = (vector_float2(*viewportSize) / 2.0).y; position.x += sin(phase); // 横方向の移動 if (position.y < -end) { position.y = end; // 一番下まで到達したら一番上に戻る } // 更新後のパーティクル情報 newParticles[gid].color = color; newParticles[gid].position = position + velocity; // velocityは下向きのy成分のみ. newParticles[gid].velocity = velocity; newParticles[gid].phase = phase + PHASE_INTERVAL; }上の2つのコードを見比べて,
setBuffer
やsetBytes
のindex
とシェーダ関数の引数にある数字とを比較すると対応関係が分かります.gid
というのはパーティクルのインデックスで, それぞれのgid
が並列で計算されていると思ってもらえばよいです. シェーダ関数simulate
に必要な情報を渡して, 関数の中で計算をしているという流れが分かります.2. 1. で更新された情報をもとにして, それぞれのパーティクルを画面上に描画
Swift側の処理
Renderer.swiftguard let vertexFunction = library.makeFunction(name: "particle_vertex") else { fatalError("Failed to make function particle_vertex.") } guard let fragmentFunction = library.makeFunction(name: "particle_fragment") else { fatalError("Failed to make function particle_fragment.") } // 関数 'particle_vertex', 'particle_fragment' を使う RenderPipeline を定義 let renderPipelineStateDescriptor = MTLRenderPipelineDescriptor() renderPipelineStateDescriptor.vertexFunction = vertexFunction renderPipelineStateDescriptor.fragmentFunction = fragmentFunction renderPipelineStateDescriptor.colorAttachments[0].pixelFormat = ... // ... renderPipelineState = try device.makeRenderPipelineState(descriptor: renderPipelineStateDescriptor) // ... private func render(in view: MTKView, commandBuffer: MTLCommandBuffer) { let renderPassDescriptor = MTLRenderPassDescriptor() renderPassDescriptor.colorAttachments[0].texture = view.currentDrawable?.texture renderPassDescriptor.colorAttachments[0].loadAction = .clear // 描画の前にテクスチャをクリア renderPassDescriptor.colorAttachments[0].clearColor = viewClearColor // 背景色の指定 renderPassDescriptor.colorAttachments[0].storeAction = .store // 描画結果を保存 guard let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor) else { return } // 上で定義したパイプライン(renderPipelineState)に従って処理をする renderEncoder.setRenderPipelineState(renderPipelineState) renderEncoder.setVertexBuffer(particleBuffers[simulatedBufferIndex], offset: 0, index: 0) // パーティクル情報 renderEncoder.setVertexBytes(&viewportSize, length: MemoryLayout<vector_float2>.size, index: 1) renderEncoder.drawPrimitives(type: .point, vertexStart: 0, vertexCount: numberOfParticles) renderEncoder.endEncoding() if let drawable = view.currentDrawable { commandBuffer.present(drawable) } }先ほどは
ComputePipiline
でしたが, ここではRenderPipeline
というものが出てきました. レンダリングについての説明をするのはとても大変なので割愛するのですが, ここでは2つのシェーダ関数を指定してパイプラインを定義しています. 上ではrenderEncoder
が GPU に渡す為の命令へのエンコードを行います.setVertexBuffer
とsetVertexBytes
で指定した情報を使って,drawable
に.point
のプリミティブを描画しています.
view.currentDrawable
というのはこれから画面上に描画されるコンテンツです.view.currentDrawable?.texture
を RenderPipeline の描画先に指定し,commandBuffer.present(drawable)
によってコンテンツが描画されたdrawable
を画面上に表示しています.シェーダ側の処理
Shaders.metalconstant float PARTICLE_SIZE = 5.0f; struct Point { float4 position [[position]]; // パーティクルの位置 float size [[point_size]]; // パーティクルの大きさ float4 color; }; vertex Point particle_vertex(const device particle_t* particles [[ buffer(0) ]], // パーティクル情報 constant vector_uint2 *viewportSizePointer [[ buffer(1) ]], // 画面サイズ unsigned int vid [[ vertex_id ]]) { Point out; out.position = vector_float4(0.0f, 0.0f, 0.0f, 1.0f); out.position.xy = particles[vid].position / (vector_float2(*viewportSizePointer) / 2.0f); out.size = PARTICLE_SIZE; out.color = particles[vid].color; return out; } fragment float4 particle_fragment(Point in [[stage_in]]) { return in.color; // 領域内の色を指定 };関数
particle_vertex
,particle_fragment
はそれぞれバーテックスシェーダ, フラグメントシェーダと呼ばれます. 先ほど.point
のプリミティブを描画すると書きましたが, ここではポイントの位置や大きさ, 領域内の色などを決めています.[[position]]
や[[point_size]]
は Metal Shading Language 5 の中で定義されている attribute と呼ばれるもので, それぞれポイントの位置, ポイントの大きさに対応しています.ここまでが大まかな処理の流れの実装を追ったものです. 細かい処理の内容は調べればキリがないですが, 全体の処理を俯瞰してみるととても単純なものだと分かります.
パフォーマンス
パーティクルを 100,000 個描画した場合の iPhone 11 Pro 上でのパフォーマンスを Xcode 上で確認しました.
右端のグラフが GPU が1フレームあたりにかけている処理時間を表していますが, まだ制限時間である 16.7ms の半分程度の余力を残しています. すごい.
展望
ここで紹介した処理は計算内容もパーティクルの描画処理も最小限のものでした. もともとはパーティクル同士の相互作用があったりなどの複雑な計算を行いたかったのですが, まずは Metal の操作に慣れるために今回の実装内容にまとめてみました. 物理演算的な計算をコンピュートシェーダ上で行って, リアルタイムシミュレーションのようなものができたら楽しいなと思っています.
もし記事の内容に不備がありましたら, 指摘していただけると幸いです?
今回のプログラムは, GPU Family 5 以上の GPU が搭載されたデバイス上でのみ動作します. プログラム中で使用している
MTLComputeCommandEncoder
の関数dispatchThreads(_:threadsPerThreadgroup:)
が動作する必要があるからです. この並列処理に関する関数は GPU Family 4 でも動作するのですが、手元に端末がなくて動作が確認できなかったので対象外としています.dispatchThreadgroups(_:threadsPerThreadgroup:)
で最適化を行うことによってもある程度は速度が出せると思うのですが, 最適化できるだけの知識がまだないので検証ができていません. ( Metal Feature Set Tables: https://developer.apple.com/metal/Metal-Feature-Set-Tables.pdf ) ↩パーティクルの情報を更新する流れは必ずしも今回のようである必要はありません. 例えば Apple のサンプルコード MetalShaderShowcase では,
birthOffset
という時間経過に相当する変数から, 方程式を使ってパーティクルの位置を計算してそのまま描画を行います. 今回, パーティクルの情報を更新して一度バッファに保存するという流れにした理由は, 将来的にパーティクルの描画処理を物理シミュレーションなどに応用したいと思っているからです. ↩Apple によると, 描画をする際にはトリプルバッファリングを使用することが推奨されています. この部分では, トリプルバッファリングで全体の描画を制御し, パーティクル情報の更新の際には同時に1つの処理しか走らないような制御を行っています. 今回のパーティクル情報の更新では一つ前のフレームで更新した情報を計算の入力として用いるので, ここで同時に2つ以上の処理が走ってしまうと正常に計算が行えないからです. Metal Best Practices Guide: Triple Buffering ↩